Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd3884de16 | ||
|
|
c646ea2d51 | ||
|
|
a22459794a | ||
|
|
91a526703e | ||
|
|
38ff8af578 | ||
|
|
58c16e9aa8 | ||
|
|
748bff63ea | ||
|
|
812c1a008e | ||
|
|
b71aa12751 | ||
|
|
c16cbdb186 | ||
|
|
8f28fa328c | ||
|
|
8af53422dc | ||
|
|
dda4bfd9d3 | ||
|
|
d92e420b34 | ||
|
|
b54fa36671 | ||
|
|
60375e93d1 | ||
|
|
eb1e4b4cfd | ||
|
|
f4eb4e7487 | ||
|
|
584f340778 | ||
|
|
b9de46f0ed | ||
|
|
0f3dcf58a5 | ||
|
|
b58e60a1f8 | ||
|
|
42b8eea0ba | ||
|
|
ce9781f1c0 | ||
|
|
e5224cbfff | ||
|
|
2d14c41070 | ||
|
|
ecb1745d9e | ||
|
|
0abc568a64 | ||
|
|
0845290929 | ||
|
|
3a0eb5fe54 | ||
|
|
503a51ca23 | ||
|
|
0781685015 | ||
|
|
2a4836c42f | ||
|
|
4ff6f2fdb0 | ||
|
|
f80ce9bc4f | ||
|
|
91bf7a359c | ||
|
|
6d7d3641ea | ||
|
|
873e0f209b | ||
|
|
8d768ca6b8 | ||
|
|
229eabfd4f | ||
|
|
80599c13d8 | ||
|
|
c92e83aadd | ||
|
|
47670fb558 | ||
|
|
b8058cd4cb | ||
|
|
4fd3e2a09b | ||
|
|
0e0fa549cf | ||
| 39e9c097a0 | |||
|
|
ea8315a993 | ||
|
|
6d33f6b0d8 | ||
|
|
8c4bbd0905 | ||
|
|
fe70fc7bd1 | ||
|
|
eb3ed957f1 | ||
| cd525898bb | |||
|
|
626d6aefdd |
5
firka/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Backend Configuration
|
||||||
|
# Development: http://192.168.X.YYY:3000/api/v1
|
||||||
|
# Production: https://your-domain.com/api/v1
|
||||||
|
BACKEND_BASE_URL=http://192.168.X.YYY:3000/api/v1
|
||||||
|
BACKEND_API_KEY=development_api_key_12345
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
BIN
firka/android/app/src/main/res/drawable-hdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
firka/android/app/src/main/res/drawable-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
firka/android/app/src/main/res/drawable-mdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
firka/android/app/src/main/res/drawable-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 14 KiB |
BIN
firka/android/app/src/main/res/drawable-night-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
BIN
firka/android/app/src/main/res/drawable-night-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
firka/android/app/src/main/res/drawable-night-v21/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
||||||
|
After Width: | Height: | Size: 16 KiB |
BIN
firka/android/app/src/main/res/drawable-night-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 24 KiB |
BIN
firka/android/app/src/main/res/drawable-night-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
BIN
firka/android/app/src/main/res/drawable-night-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
firka/android/app/src/main/res/drawable-night/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
||||||
BIN
firka/android/app/src/main/res/drawable-v21/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -1,12 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<item>
|
||||||
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
<!-- You can insert your own image assets here -->
|
</item>
|
||||||
<!-- <item>
|
<item>
|
||||||
<bitmap
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
android:gravity="center"
|
</item>
|
||||||
android:src="@mipmap/launch_image" />
|
|
||||||
</item> -->
|
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 16 KiB |
BIN
firka/android/app/src/main/res/drawable-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 24 KiB |
BIN
firka/android/app/src/main/res/drawable-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
BIN
firka/android/app/src/main/res/drawable-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
firka/android/app/src/main/res/drawable/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -1,12 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<item>
|
||||||
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
<!-- You can insert your own image assets here -->
|
</item>
|
||||||
<!-- <item>
|
<item>
|
||||||
<bitmap
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
android:gravity="center"
|
</item>
|
||||||
android:src="@mipmap/launch_image" />
|
|
||||||
</item> -->
|
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
21
firka/android/app/src/main/res/values-night-v31/styles.xml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
|
<item name="android:windowFullscreen">false</item>
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
|
<item name="android:windowSplashScreenBackground">#7ca120</item>
|
||||||
|
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -5,6 +5,10 @@
|
|||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
|
<item name="android:windowFullscreen">false</item>
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
This theme determines the color of the Android Window while your
|
||||||
|
|||||||
21
firka/android/app/src/main/res/values-v31/styles.xml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
|
<item name="android:windowFullscreen">false</item>
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
|
<item name="android:windowSplashScreenBackground">#7ca120</item>
|
||||||
|
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -5,6 +5,10 @@
|
|||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
|
<item name="android:windowFullscreen">false</item>
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
This theme determines the color of the Android Window while your
|
||||||
|
|||||||
16
firka/flutter_native_splash.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
flutter_native_splash:
|
||||||
|
color: "#7ca120"
|
||||||
|
image: assets/images/logos/splash.png
|
||||||
|
|
||||||
|
# Dark mode - same color as light mode for consistency
|
||||||
|
color_dark: "#7ca120"
|
||||||
|
image_dark: assets/images/logos/splash.png
|
||||||
|
|
||||||
|
android_12:
|
||||||
|
image: assets/images/logos/splash.png
|
||||||
|
color: "#7ca120"
|
||||||
|
color_dark: "#7ca120"
|
||||||
|
image_dark: assets/images/logos/splash.png
|
||||||
|
|
||||||
|
ios: true
|
||||||
|
web: false
|
||||||
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,14 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "Icon-1024.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "watchos",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 147 KiB |
@@ -0,0 +1,64 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
private var clampedRemainingMinutes: Int {
|
||||||
|
guard totalMinutes > 0 else { return 0 }
|
||||||
|
return max(0, min(remainingMinutes, totalMinutes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var progress: Double {
|
||||||
|
guard totalMinutes > 0 else { return 0 }
|
||||||
|
return Double(totalMinutes - clampedRemainingMinutes) / Double(totalMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayedMinutes: Int {
|
||||||
|
max(0, remainingMinutes + displayOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ringColor: Color {
|
||||||
|
if clampedRemainingMinutes < 5 { return .red }
|
||||||
|
if clampedRemainingMinutes < 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()
|
||||||
|
}
|
||||||
42
firka/ios/FirkaWatch Watch App/Components/FirkaCard.swift
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FirkaCard<Content: View>: View {
|
||||||
|
let content: Content
|
||||||
|
var isHighlighted: Bool = false
|
||||||
|
var backgroundColor: Color? = nil
|
||||||
|
|
||||||
|
init(
|
||||||
|
isHighlighted: Bool = false,
|
||||||
|
backgroundColor: Color? = nil,
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) {
|
||||||
|
self.isHighlighted = isHighlighted
|
||||||
|
self.backgroundColor = backgroundColor
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
content
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
backgroundColor ??
|
||||||
|
(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
@@ -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
@@ -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
@@ -0,0 +1,146 @@
|
|||||||
|
import 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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
189
firka/ios/FirkaWatch Watch App/ContentView.swift
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WatchConnectivity
|
||||||
|
internal import Combine
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
var dataStore = DataStore.shared
|
||||||
|
@State private var selectedTab = 0
|
||||||
|
@State private var isRequestingToken = false
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
|
private let staleCheckTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
|
private let autoRefreshThreshold: TimeInterval = 10 * 60
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if dataStore.isRecoveringToken {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(1.2)
|
||||||
|
Text("recovering_token".localized)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
else if dataStore.needsReauth && dataStore.hasToken {
|
||||||
|
ReauthRequiredView(onTokenReceived: {
|
||||||
|
dataStore.resetRecoveryState()
|
||||||
|
dataStore.checkTokenState()
|
||||||
|
Task {
|
||||||
|
await dataStore.refreshAllWithRecovery()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if !dataStore.hasToken && dataStore.data == nil {
|
||||||
|
if isRequestingToken {
|
||||||
|
ProgressView("connecting".localized)
|
||||||
|
} else {
|
||||||
|
PairingView(onRequestToken: requestToken)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mainContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
dataStore.reconcileSharedSessionState()
|
||||||
|
WatchL10n.shared.reconcileFromSharedState()
|
||||||
|
dataStore.checkTokenState()
|
||||||
|
dataStore.loadFromCache()
|
||||||
|
if dataStore.hasToken {
|
||||||
|
await dataStore.refreshAllWithRecovery()
|
||||||
|
} else {
|
||||||
|
requestToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: scenePhase) { oldPhase, newPhase in
|
||||||
|
if newPhase == .active && oldPhase != .active {
|
||||||
|
dataStore.reconcileSharedSessionState()
|
||||||
|
WatchL10n.shared.reconcileFromSharedState()
|
||||||
|
if shouldAutoRefresh {
|
||||||
|
print("[Watch] App came to foreground, data is stale (>10 min), refreshing...")
|
||||||
|
Task {
|
||||||
|
await dataStore.refreshAllWithRecovery()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("[Watch] App came to foreground, data is fresh (<10 min), skipping refresh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onReceive(staleCheckTimer) { _ in
|
||||||
|
guard scenePhase == .active else { return }
|
||||||
|
dataStore.reconcileSharedSessionState()
|
||||||
|
WatchL10n.shared.reconcileFromSharedState()
|
||||||
|
|
||||||
|
if !dataStore.hasToken {
|
||||||
|
dataStore.checkTokenState()
|
||||||
|
if dataStore.hasToken {
|
||||||
|
print("[Watch] Token appeared (iCloud Keychain sync?), refreshing...")
|
||||||
|
Task {
|
||||||
|
await dataStore.refreshAllWithRecovery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldAutoRefresh && !dataStore.isLoading {
|
||||||
|
print("[Watch] Data became stale (>10 min), auto-refreshing...")
|
||||||
|
Task {
|
||||||
|
await dataStore.refreshAllWithRecovery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shouldAutoRefresh: Bool {
|
||||||
|
guard dataStore.hasToken else { return false }
|
||||||
|
guard let lastUpdated = dataStore.lastUpdated else { return true }
|
||||||
|
let elapsed = Date().timeIntervalSince(lastUpdated)
|
||||||
|
return elapsed >= autoRefreshThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
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)?
|
||||||
|
|
||||||
|
private var isWatchSystemPaired: Bool {
|
||||||
|
guard WCSession.isSupported() else { return false }
|
||||||
|
return WCSession.default.isCompanionAppInstalled
|
||||||
|
}
|
||||||
|
|
||||||
|
private var titleKey: String {
|
||||||
|
isWatchSystemPaired ? "login_on_iphone" : "pair_with_iphone"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var descriptionKey: String {
|
||||||
|
isWatchSystemPaired ? "open_and_login_on_iphone" : "open_firka_on_iphone"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconName: String {
|
||||||
|
isWatchSystemPaired
|
||||||
|
? "person.crop.circle.badge.exclamationmark"
|
||||||
|
: "iphone.and.arrow.right.inward"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Image(systemName: iconName)
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
|
||||||
|
Text(titleKey.localized)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text(descriptionKey.localized)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
if isWatchSystemPaired {
|
||||||
|
Button("sync_button".localized) {
|
||||||
|
onRequestToken?()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?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>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)app.firka.shared</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
|
<string>$(TeamIdentifierPrefix)app.firka.firkaa</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
35
firka/ios/FirkaWatch Watch App/FirkaWatchApp.swift
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WatchKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct FirkaWatchApp: App {
|
||||||
|
@WKApplicationDelegateAdaptor(WatchAppDelegate.self) var delegate
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
464
firka/ios/FirkaWatch Watch App/Localization/WatchL10n.swift
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
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 let lastAppliedSharedLanguageVersionKey = "watch_last_applied_shared_language_version"
|
||||||
|
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)
|
||||||
|
appGroupDefaults?.set(syncWithiPhone, forKey: syncWithiPhoneKey)
|
||||||
|
if syncWithiPhone {
|
||||||
|
refreshFromiPhoneAndSharedState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var strings: [String: String] = [:]
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let savedLanguage = UserDefaults.standard.string(forKey: languageKey) ?? "hu"
|
||||||
|
self.currentLanguage = WatchLanguage(rawValue: savedLanguage) ?? .hungarian
|
||||||
|
if let storedSyncPref = UserDefaults.standard.object(forKey: syncWithiPhoneKey) as? Bool {
|
||||||
|
self.syncWithiPhone = storedSyncPref
|
||||||
|
} else {
|
||||||
|
self.syncWithiPhone = true
|
||||||
|
UserDefaults.standard.set(true, forKey: syncWithiPhoneKey)
|
||||||
|
appGroupDefaults?.set(true, forKey: syncWithiPhoneKey)
|
||||||
|
}
|
||||||
|
appGroupDefaults?.set(currentLanguage.rawValue, forKey: languageKey)
|
||||||
|
loadStrings()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadStrings() {
|
||||||
|
strings = Self.stringsForLanguage(currentLanguage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setLanguage(_ language: WatchLanguage) {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
currentLanguage = language
|
||||||
|
loadStrings()
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async { [self] in
|
||||||
|
currentLanguage = language
|
||||||
|
loadStrings()
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFromiPhone(languageCode: String, sharedStateVersion: Int64? = nil) {
|
||||||
|
guard syncWithiPhone else { return }
|
||||||
|
let lastAppliedVersion = lastAppliedSharedLanguageVersion()
|
||||||
|
if let sharedStateVersion,
|
||||||
|
sharedStateVersion > 0,
|
||||||
|
sharedStateVersion < lastAppliedVersion {
|
||||||
|
print("[WatchL10n] Ignoring stale WC language update (version: \(sharedStateVersion), lastApplied: \(lastAppliedVersion))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let language = WatchLanguage(rawValue: languageCode) {
|
||||||
|
if language != currentLanguage {
|
||||||
|
setLanguage(language)
|
||||||
|
}
|
||||||
|
if let sharedStateVersion, sharedStateVersion > 0 {
|
||||||
|
setLastAppliedSharedLanguageVersion(max(lastAppliedVersion, sharedStateVersion))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseInt64(_ value: Any?) -> Int64? {
|
||||||
|
if let value = value as? Int64 { return value }
|
||||||
|
if let value = value as? Int { return Int64(value) }
|
||||||
|
if let value = value as? Double { return Int64(value) }
|
||||||
|
if let value = value as? String, let parsed = Int64(value) { return parsed }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lastAppliedSharedLanguageVersion() -> Int64 {
|
||||||
|
parseInt64(UserDefaults.standard.object(forKey: lastAppliedSharedLanguageVersionKey)) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setLastAppliedSharedLanguageVersion(_ value: Int64) {
|
||||||
|
UserDefaults.standard.set(value, forKey: lastAppliedSharedLanguageVersionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetLanguageVersionTracking() {
|
||||||
|
setLastAppliedSharedLanguageVersion(0)
|
||||||
|
print("[WatchL10n] Language version tracking reset for account switch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconcileFromSharedState() {
|
||||||
|
guard syncWithiPhone else { return }
|
||||||
|
guard let sharedState = SharedLanguageStateManager.shared.loadState() else { return }
|
||||||
|
let lastAppliedVersion = lastAppliedSharedLanguageVersion()
|
||||||
|
guard sharedState.stateVersion > lastAppliedVersion else { return }
|
||||||
|
|
||||||
|
if let language = WatchLanguage(rawValue: sharedState.languageCode) {
|
||||||
|
if language != currentLanguage {
|
||||||
|
setLanguage(language)
|
||||||
|
}
|
||||||
|
setLastAppliedSharedLanguageVersion(sharedState.stateVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshFromiPhoneAndSharedState() {
|
||||||
|
guard syncWithiPhone else { return }
|
||||||
|
requestLanguageFromiPhone()
|
||||||
|
reconcileFromSharedState()
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
"login_on_iphone": "Jelentkezz be iPhone-on",
|
||||||
|
"open_and_login_on_iphone": "Nyisd meg a Firka appot iPhone-on, és lépj be egy fiókba",
|
||||||
|
"updated": "Frissítve: %@",
|
||||||
|
"minutes": "perc",
|
||||||
|
"time_now": "most",
|
||||||
|
"time_hours_minutes": "%d ó %d p",
|
||||||
|
"time_hours": "%d óra",
|
||||||
|
"time_minutes_only": "%d perc",
|
||||||
|
"time_since_minutes_one": "1 perce",
|
||||||
|
"time_since_minutes_many": "%d perce",
|
||||||
|
"time_since_hours_one": "1 órája",
|
||||||
|
"time_since_hours_many": "%d órája",
|
||||||
|
"time_since_days_one": "1 napja",
|
||||||
|
"time_since_days_many": "%d napja",
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
"auto": "Automatikus",
|
||||||
|
"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...",
|
||||||
|
"recovering_token": "Token helyreállítása...",
|
||||||
|
]
|
||||||
|
|
||||||
|
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",
|
||||||
|
"login_on_iphone": "Sign in on iPhone",
|
||||||
|
"open_and_login_on_iphone": "Open Firka on your iPhone and sign in to an account",
|
||||||
|
"updated": "Updated: %@",
|
||||||
|
"minutes": "min",
|
||||||
|
"time_now": "now",
|
||||||
|
"time_hours_minutes": "%dh %dm",
|
||||||
|
"time_hours": "%d hours",
|
||||||
|
"time_minutes_only": "%d min",
|
||||||
|
"time_since_minutes_one": "1 min ago",
|
||||||
|
"time_since_minutes_many": "%d mins ago",
|
||||||
|
"time_since_hours_one": "1 hour ago",
|
||||||
|
"time_since_hours_many": "%d hours ago",
|
||||||
|
"time_since_days_one": "1 day ago",
|
||||||
|
"time_since_days_many": "%d days ago",
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
"auto": "Auto",
|
||||||
|
"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...",
|
||||||
|
"recovering_token": "Recovering session...",
|
||||||
|
]
|
||||||
|
|
||||||
|
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",
|
||||||
|
"login_on_iphone": "Auf iPhone anmelden",
|
||||||
|
"open_and_login_on_iphone": "Öffne Firka auf deinem iPhone und melde dich mit einem Konto an",
|
||||||
|
"updated": "Aktualisiert: %@",
|
||||||
|
"minutes": "Min",
|
||||||
|
"time_now": "jetzt",
|
||||||
|
"time_hours_minutes": "%d Std %d Min",
|
||||||
|
"time_hours": "%d Stunden",
|
||||||
|
"time_minutes_only": "%d Min",
|
||||||
|
"time_since_minutes_one": "vor 1 Min",
|
||||||
|
"time_since_minutes_many": "vor %d Min",
|
||||||
|
"time_since_hours_one": "vor 1 Std",
|
||||||
|
"time_since_hours_many": "vor %d Std",
|
||||||
|
"time_since_days_one": "vor 1 Tag",
|
||||||
|
"time_since_days_many": "vor %d Tagen",
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
"auto": "Automatisch",
|
||||||
|
"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...",
|
||||||
|
"recovering_token": "Sitzung wiederherstellen...",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
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,120 @@
|
|||||||
|
import Foundation
|
||||||
|
import WatchKit
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
class BackgroundRefreshManager {
|
||||||
|
static let shared = BackgroundRefreshManager()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func scheduleNextRefresh() {
|
||||||
|
let interval = calculateOptimalRefreshInterval()
|
||||||
|
|
||||||
|
let preferredDate = Date().addingTimeInterval(interval)
|
||||||
|
WKApplication.shared().scheduleBackgroundRefresh(
|
||||||
|
withPreferredDate: preferredDate,
|
||||||
|
userInfo: nil
|
||||||
|
) { error in
|
||||||
|
if let error = error {
|
||||||
|
print("[BackgroundRefresh] Schedule error: \(error)")
|
||||||
|
} else {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "HH:mm"
|
||||||
|
print("[BackgroundRefresh] Next refresh scheduled at: \(formatter.string(from: preferredDate)) (\(Int(interval/60)) min)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateOptimalRefreshInterval() -> TimeInterval {
|
||||||
|
let userRefreshMinutes = UserDefaults.standard.integer(forKey: "refreshInterval")
|
||||||
|
let now = Date()
|
||||||
|
let calendar = Calendar.current
|
||||||
|
|
||||||
|
let todayLessons = getTodayLessons()
|
||||||
|
|
||||||
|
guard !todayLessons.isEmpty else {
|
||||||
|
return getDefaultInterval(userSetting: userRefreshMinutes, now: now, calendar: calendar)
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortedLessons = todayLessons.sorted { $0.start < $1.start }
|
||||||
|
|
||||||
|
guard let firstLesson = sortedLessons.first,
|
||||||
|
let lastLesson = sortedLessons.last else {
|
||||||
|
return getDefaultInterval(userSetting: userRefreshMinutes, now: now, calendar: calendar)
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstStart = firstLesson.start
|
||||||
|
let lastEnd = lastLesson.end
|
||||||
|
|
||||||
|
let schoolStartBuffer = firstStart.addingTimeInterval(-30 * 60)
|
||||||
|
|
||||||
|
if now < schoolStartBuffer {
|
||||||
|
let intervalUntilWakeUp = schoolStartBuffer.timeIntervalSince(now)
|
||||||
|
let interval = max(intervalUntilWakeUp, 15 * 60)
|
||||||
|
print("[BackgroundRefresh] Before school - next refresh in \(Int(interval/60)) min (30 min before first lesson)")
|
||||||
|
return min(interval, 60 * 60) // Max 1 hour
|
||||||
|
}
|
||||||
|
|
||||||
|
if now >= schoolStartBuffer && now <= lastEnd {
|
||||||
|
let interval = TimeInterval((userRefreshMinutes > 0 ? userRefreshMinutes : 15) * 60)
|
||||||
|
print("[BackgroundRefresh] During school - using \(Int(interval/60)) min interval")
|
||||||
|
return interval
|
||||||
|
}
|
||||||
|
|
||||||
|
let tomorrowLessons = getTomorrowLessons()
|
||||||
|
if !tomorrowLessons.isEmpty,
|
||||||
|
let tomorrowFirst = tomorrowLessons.sorted(by: { $0.start < $1.start }).first {
|
||||||
|
|
||||||
|
let tomorrowStartBuffer = tomorrowFirst.start.addingTimeInterval(-30 * 60)
|
||||||
|
let timeUntilTomorrowWakeUp = tomorrowStartBuffer.timeIntervalSince(now)
|
||||||
|
|
||||||
|
if timeUntilTomorrowWakeUp > 2 * 60 * 60 {
|
||||||
|
print("[BackgroundRefresh] After school - 1 hour interval (tomorrow's first lesson in \(Int(timeUntilTomorrowWakeUp/60)) min)")
|
||||||
|
return 60 * 60
|
||||||
|
} else {
|
||||||
|
print("[BackgroundRefresh] After school, tomorrow soon - 30 min interval")
|
||||||
|
return 30 * 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[BackgroundRefresh] After school, no tomorrow lessons - 1 hour interval")
|
||||||
|
return 60 * 60
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getDefaultInterval(userSetting: Int, now: Date, calendar: Calendar) -> TimeInterval {
|
||||||
|
if userSetting > 0 {
|
||||||
|
print("[BackgroundRefresh] No timetable - using user setting: \(userSetting) min")
|
||||||
|
return TimeInterval(userSetting * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
let hour = calendar.component(.hour, from: now)
|
||||||
|
let weekday = calendar.component(.weekday, from: now)
|
||||||
|
let isWeekday = weekday >= 2 && weekday <= 6
|
||||||
|
|
||||||
|
if isWeekday && hour >= 6 && hour <= 16 {
|
||||||
|
print("[BackgroundRefresh] No timetable - weekday school hours: 15 min")
|
||||||
|
return 15 * 60
|
||||||
|
} else {
|
||||||
|
print("[BackgroundRefresh] No timetable - off hours: 1 hour")
|
||||||
|
return 60 * 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getTodayLessons() -> [WidgetLesson] {
|
||||||
|
guard let data = DataStore.shared.data else { return [] }
|
||||||
|
return data.timetable.today
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getTomorrowLessons() -> [WidgetLesson] {
|
||||||
|
guard let data = DataStore.shared.data else { return [] }
|
||||||
|
return data.timetable.tomorrow
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleBackgroundRefresh() async {
|
||||||
|
await DataStore.shared.refreshAllWithRecovery()
|
||||||
|
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
|
||||||
|
scheduleNextRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
551
firka/ios/FirkaWatch Watch App/Services/DataStore.swift
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
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?
|
||||||
|
|
||||||
|
var isRecoveringToken: Bool = false
|
||||||
|
|
||||||
|
private(set) var recoveryAttempted: Bool = false
|
||||||
|
|
||||||
|
private(set) var hasToken: Bool = false
|
||||||
|
|
||||||
|
var needsReauth: Bool {
|
||||||
|
(error == "token_expired" || error == "no_token") && recoveryAttempted && !isRecoveringToken
|
||||||
|
}
|
||||||
|
|
||||||
|
private let appGroupID = "group.app.firka.firkaa"
|
||||||
|
private let cacheFileName = "watch_data.json"
|
||||||
|
private let lastHandledSessionStateVersionKey = "firka.watch.last_handled_session_state_version"
|
||||||
|
private let lastHandledSessionActiveStudentIdNormKey = "firka.watch.last_handled_session_active_student_id_norm"
|
||||||
|
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseInt64(_ value: Any?) -> Int64? {
|
||||||
|
if let value = value as? Int64 { return value }
|
||||||
|
if let value = value as? Int { return Int64(value) }
|
||||||
|
if let value = value as? Double { return Int64(value) }
|
||||||
|
if let value = value as? String, let parsed = Int64(value) { return parsed }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lastHandledSessionStateVersion() -> Int64 {
|
||||||
|
parseInt64(UserDefaults.standard.object(forKey: lastHandledSessionStateVersionKey)) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setLastHandledSessionStateVersion(_ value: Int64) {
|
||||||
|
UserDefaults.standard.set(value, forKey: lastHandledSessionStateVersionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lastHandledSessionActiveStudentIdNorm() -> Int64? {
|
||||||
|
parseInt64(UserDefaults.standard.object(forKey: lastHandledSessionActiveStudentIdNormKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setLastHandledSessionActiveStudentIdNorm(_ value: Int64?) {
|
||||||
|
if let value {
|
||||||
|
UserDefaults.standard.set(value, forKey: lastHandledSessionActiveStudentIdNormKey)
|
||||||
|
} else {
|
||||||
|
UserDefaults.standard.removeObject(forKey: lastHandledSessionActiveStudentIdNormKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconcileSharedSessionState() {
|
||||||
|
guard let state = SharedSessionStateManager.shared.loadState() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastVersion = lastHandledSessionStateVersion()
|
||||||
|
guard state.stateVersion > lastVersion else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.hasAnyAccount {
|
||||||
|
print("[Watch] Shared session state: no active iPhone account, clearing watch state")
|
||||||
|
clearAll()
|
||||||
|
resetRecoveryState()
|
||||||
|
setLastHandledSessionStateVersion(state.stateVersion)
|
||||||
|
setLastHandledSessionActiveStudentIdNorm(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let activeStudentIdNorm = state.activeStudentIdNorm {
|
||||||
|
let lastHandledActiveStudentIdNorm = lastHandledSessionActiveStudentIdNorm()
|
||||||
|
if lastHandledActiveStudentIdNorm != activeStudentIdNorm {
|
||||||
|
print("[Watch] Shared session switched active account to \(activeStudentIdNorm), clearing stale cache")
|
||||||
|
clearCache()
|
||||||
|
data = nil
|
||||||
|
lastUpdated = nil
|
||||||
|
error = nil
|
||||||
|
recoveryAttempted = false
|
||||||
|
WatchL10n.shared.resetLanguageVersionTracking()
|
||||||
|
}
|
||||||
|
setLastHandledSessionActiveStudentIdNorm(activeStudentIdNorm)
|
||||||
|
} else {
|
||||||
|
setLastHandledSessionActiveStudentIdNorm(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastHandledSessionStateVersion(state.stateVersion)
|
||||||
|
checkTokenState()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetRecoveryState() {
|
||||||
|
recoveryAttempted = false
|
||||||
|
error = nil
|
||||||
|
print("[Watch] Recovery state reset")
|
||||||
|
}
|
||||||
|
|
||||||
|
func attemptTokenRecovery() async -> Bool {
|
||||||
|
guard !isRecoveringToken else {
|
||||||
|
print("[Watch] Token recovery already in progress")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
isRecoveringToken = true
|
||||||
|
recoveryAttempted = false
|
||||||
|
error = nil
|
||||||
|
print("[Watch] Starting token recovery via central method...")
|
||||||
|
|
||||||
|
defer {
|
||||||
|
isRecoveringToken = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if let token = TokenManager.shared.loadToken(), !TokenManager.shared.isTokenExpired() {
|
||||||
|
print("[Watch] Recovery: Token is already valid")
|
||||||
|
checkTokenState()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if let _ = await TokenManager.shared.recoverToken() {
|
||||||
|
print("[Watch] Recovery: Central recovery succeeded")
|
||||||
|
checkTokenState()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[Watch] Recovery: All attempts failed")
|
||||||
|
recoveryAttempted = true
|
||||||
|
self.error = "token_expired"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
guard !isLoading else {
|
||||||
|
print("[Watch] DataStore.refreshAll() already in progress, skipping duplicate call")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isRecoveryInProgress: Bool = false
|
||||||
|
|
||||||
|
func refreshAllWithRecovery() async {
|
||||||
|
guard !isRecoveryInProgress && !isLoading else {
|
||||||
|
print("[Watch] refreshAllWithRecovery() already in progress or refreshAll() running, skipping duplicate call")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isRecoveryInProgress = true
|
||||||
|
defer { isRecoveryInProgress = false }
|
||||||
|
|
||||||
|
reconcileSharedSessionState()
|
||||||
|
WatchL10n.shared.refreshFromiPhoneAndSharedState()
|
||||||
|
|
||||||
|
let sharedActiveStudentIdNorm = SharedSessionStateManager.shared.loadState()?.activeStudentIdNorm
|
||||||
|
let localStudentIdNorm = TokenManager.shared.loadToken()?.studentIdNorm
|
||||||
|
let shouldRequestTokenFromPhone =
|
||||||
|
!hasValidToken ||
|
||||||
|
(sharedActiveStudentIdNorm != nil && localStudentIdNorm != sharedActiveStudentIdNorm)
|
||||||
|
|
||||||
|
if shouldRequestTokenFromPhone {
|
||||||
|
WatchConnectivityManager.shared.requestTokenFromPhone()
|
||||||
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
checkTokenState()
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshAll()
|
||||||
|
|
||||||
|
guard error == "token_expired" || error == "no_token" else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[Watch] Token issue after refreshAll(), starting auto-recovery flow...")
|
||||||
|
let recovered = await attemptTokenRecovery()
|
||||||
|
if recovered {
|
||||||
|
await refreshAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.normalizedNumericValue {
|
||||||
|
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 "time_now".localized
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minutes
|
||||||
|
let minutes = Int(elapsed / 60)
|
||||||
|
if minutes < 60 {
|
||||||
|
return minutes == 1
|
||||||
|
? "time_since_minutes_one".localized
|
||||||
|
: "time_since_minutes_many".localized(minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hours
|
||||||
|
let hours = Int(elapsed / 3600)
|
||||||
|
if hours < 24 {
|
||||||
|
return hours == 1
|
||||||
|
? "time_since_hours_one".localized
|
||||||
|
: "time_since_hours_many".localized(hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Days
|
||||||
|
let days = Int(elapsed / 86400)
|
||||||
|
return days == 1
|
||||||
|
? "time_since_days_one".localized
|
||||||
|
: "time_since_days_many".localized(days)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,456 @@
|
|||||||
|
import Foundation
|
||||||
|
import WatchConnectivity
|
||||||
|
|
||||||
|
class WatchConnectivityManager: NSObject, WCSessionDelegate {
|
||||||
|
static let shared = WatchConnectivityManager()
|
||||||
|
private let lastAppliedTokenUpdateKey = "watch_last_applied_token_update_ms"
|
||||||
|
private let minPhoneTokenRequestInterval: TimeInterval = 5
|
||||||
|
private var lastPhoneTokenRequestAt: Date?
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastAppliedTokenUpdateMs: Int64 {
|
||||||
|
get {
|
||||||
|
Int64(UserDefaults.standard.double(forKey: lastAppliedTokenUpdateKey))
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
UserDefaults.standard.set(Double(newValue), forKey: lastAppliedTokenUpdateKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extractSentAtMs(from authDict: [String: Any]) -> Int64? {
|
||||||
|
if let value = authDict["sentAtMs"] as? Int64 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if let value = authDict["sentAtMs"] as? Int {
|
||||||
|
return Int64(value)
|
||||||
|
}
|
||||||
|
if let value = authDict["sentAtMs"] as? Double {
|
||||||
|
return Int64(value)
|
||||||
|
}
|
||||||
|
if let value = authDict["sentAtMs"] as? String,
|
||||||
|
let parsed = Int64(value) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseInt64(_ value: Any?) -> Int64? {
|
||||||
|
if let value = value as? Int64 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if let value = value as? Int {
|
||||||
|
return Int64(value)
|
||||||
|
}
|
||||||
|
if let value = value as? Double {
|
||||||
|
return Int64(value)
|
||||||
|
}
|
||||||
|
if let value = value as? String, let parsed = Int64(value) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)")
|
||||||
|
|
||||||
|
if let messageId = message["id"] as? String, messageId == "token_update" {
|
||||||
|
if let authDict = message["auth"] as? [String: Any] {
|
||||||
|
print("[Watch] Received immediate token_update via sendMessage")
|
||||||
|
processAuthData(authDict)
|
||||||
|
replyHandler(["success": true])
|
||||||
|
} else {
|
||||||
|
replyHandler(["error": "no_auth"])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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 TokenManager.shared.loadToken() != nil else {
|
||||||
|
print("[Watch] No token to send to iPhone")
|
||||||
|
replyHandler(["error": "no_token"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if TokenManager.shared.isTokenExpired() {
|
||||||
|
print("[Watch] Token expired, attempting refresh before sending to iPhone...")
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let freshToken = try await KretaAPIClient.shared.getValidToken()
|
||||||
|
print("[Watch] Token refresh succeeded, sending fresh token to iPhone")
|
||||||
|
|
||||||
|
var tokenData: [String: Any] = [
|
||||||
|
"studentId": freshToken.studentId,
|
||||||
|
"studentIdNorm": freshToken.studentIdNorm,
|
||||||
|
"iss": freshToken.iss,
|
||||||
|
"idToken": freshToken.idToken,
|
||||||
|
"accessToken": freshToken.accessToken,
|
||||||
|
"refreshToken": freshToken.refreshToken,
|
||||||
|
"expiryDate": Int64(freshToken.expiryDate.timeIntervalSince1970 * 1000)
|
||||||
|
]
|
||||||
|
if let tokenVersion = freshToken.effectiveTokenVersion {
|
||||||
|
tokenData["tokenVersion"] = tokenVersion
|
||||||
|
}
|
||||||
|
tokenData["updatedAtMs"] = freshToken.effectiveUpdatedAtMs ?? Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
|
||||||
|
replyHandler(["token": tokenData])
|
||||||
|
} catch {
|
||||||
|
print("[Watch] Token refresh failed after all retries: \(error)")
|
||||||
|
replyHandler(["error": "refresh_failed"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let token = TokenManager.shared.loadToken() else {
|
||||||
|
replyHandler(["error": "no_token"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var 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)
|
||||||
|
]
|
||||||
|
if let tokenVersion = token.effectiveTokenVersion {
|
||||||
|
tokenData["tokenVersion"] = tokenVersion
|
||||||
|
}
|
||||||
|
tokenData["updatedAtMs"] = token.effectiveUpdatedAtMs ?? Int64(Date().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
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
if let lastPhoneTokenRequestAt,
|
||||||
|
now.timeIntervalSince(lastPhoneTokenRequestAt) < minPhoneTokenRequestInterval {
|
||||||
|
print("[Watch] Skipping token request due to cooldown")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastPhoneTokenRequestAt = now
|
||||||
|
|
||||||
|
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 (context["force_logout"] as? Bool) == true {
|
||||||
|
print("[Watch] Received force_logout via applicationContext")
|
||||||
|
handleForceLogoutFromPhone()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let authDict = context["auth"] as? [String: Any] {
|
||||||
|
print("[Watch] Received auth from iPhone")
|
||||||
|
processAuthData(authDict)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let language = context["language"] as? String {
|
||||||
|
let sharedStateVersion =
|
||||||
|
parseInt64(context["language_state_version"]) ??
|
||||||
|
parseInt64(context["languageStateVersion"])
|
||||||
|
print("[Watch] Received language from iPhone: \(language)")
|
||||||
|
WatchL10n.shared.updateFromiPhone(
|
||||||
|
languageCode: language,
|
||||||
|
sharedStateVersion: sharedStateVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let sharedStateVersion =
|
||||||
|
parseInt64(userInfo["language_state_version"]) ??
|
||||||
|
parseInt64(userInfo["languageStateVersion"])
|
||||||
|
print("[Watch] Received language_update via userInfo: \(language)")
|
||||||
|
WatchL10n.shared.updateFromiPhone(
|
||||||
|
languageCode: language,
|
||||||
|
sharedStateVersion: sharedStateVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case "reauth_required":
|
||||||
|
print("[Watch] Received reauth_required notification from iPhone")
|
||||||
|
DataStore.shared.setReauthRequired()
|
||||||
|
case "force_logout":
|
||||||
|
print("[Watch] Received force_logout notification from iPhone")
|
||||||
|
handleForceLogoutFromPhone()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleForceLogoutFromPhone() {
|
||||||
|
TokenManager.shared.deleteToken()
|
||||||
|
_ = SharedSessionStateManager.shared.publishState(
|
||||||
|
hasAnyAccount: false,
|
||||||
|
activeStudentIdNorm: nil
|
||||||
|
)
|
||||||
|
DataStore.shared.clearAll()
|
||||||
|
DataStore.shared.resetRecoveryState()
|
||||||
|
DataStore.shared.checkTokenState()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
var 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)
|
||||||
|
]
|
||||||
|
if let tokenVersion = token.effectiveTokenVersion {
|
||||||
|
tokenData["tokenVersion"] = tokenVersion
|
||||||
|
}
|
||||||
|
tokenData["updatedAtMs"] = token.effectiveUpdatedAtMs ?? Int64(Date().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 {
|
||||||
|
let sharedStateVersion =
|
||||||
|
self.parseInt64(response["language_state_version"]) ??
|
||||||
|
self.parseInt64(response["languageStateVersion"])
|
||||||
|
print("[Watch] Language received from iPhone: \(language)")
|
||||||
|
WatchL10n.shared.updateFromiPhone(
|
||||||
|
languageCode: language,
|
||||||
|
sharedStateVersion: sharedStateVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
errorHandler: { error in
|
||||||
|
print("[Watch] Language request failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processAuthData(_ authDict: [String: Any]) {
|
||||||
|
print("[Watch] processAuthData called")
|
||||||
|
do {
|
||||||
|
let incomingSentAtMs = extractSentAtMs(from: authDict) ?? 0
|
||||||
|
let previousSentAtMs = lastAppliedTokenUpdateMs
|
||||||
|
|
||||||
|
if incomingSentAtMs > 0 && incomingSentAtMs < previousSentAtMs {
|
||||||
|
print("[Watch] Ignoring stale token_update (sentAtMs: \(incomingSentAtMs), lastApplied: \(previousSentAtMs))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
let currentToken = TokenManager.shared.loadToken()
|
||||||
|
|
||||||
|
let isAccountSwitch = currentToken != nil && !token.isSameAccount(as: currentToken!)
|
||||||
|
let shouldForceAccountSwitch: Bool
|
||||||
|
if isAccountSwitch {
|
||||||
|
if incomingSentAtMs > 0 {
|
||||||
|
shouldForceAccountSwitch = true
|
||||||
|
} else {
|
||||||
|
shouldForceAccountSwitch = token.isNewer(than: currentToken!)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
shouldForceAccountSwitch = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if incomingSentAtMs <= 0,
|
||||||
|
let currentToken,
|
||||||
|
!isAccountSwitch,
|
||||||
|
!token.isNewer(than: currentToken) {
|
||||||
|
print("[Watch] Ignoring stale token_update without sentAtMs (same account, not newer)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[Watch] Token decoded, saving... (sentAtMs: \(incomingSentAtMs), forceSwitch: \(shouldForceAccountSwitch))")
|
||||||
|
|
||||||
|
try TokenManager.shared.saveToken(
|
||||||
|
token,
|
||||||
|
syncToSharedKeychain: false,
|
||||||
|
forceAccountSwitch: shouldForceAccountSwitch
|
||||||
|
)
|
||||||
|
print("[Watch] Token saved successfully")
|
||||||
|
_ = SharedSessionStateManager.shared.publishState(
|
||||||
|
hasAnyAccount: true,
|
||||||
|
activeStudentIdNorm: token.studentIdNorm
|
||||||
|
)
|
||||||
|
if incomingSentAtMs > 0 {
|
||||||
|
lastAppliedTokenUpdateMs = max(previousSentAtMs, incomingSentAtMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
DataStore.shared.clearError()
|
||||||
|
DataStore.shared.resetRecoveryState()
|
||||||
|
DataStore.shared.checkTokenState()
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await DataStore.shared.refreshAllWithRecovery()
|
||||||
|
print("[Watch] Data refresh completed")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("[Watch] Failed to process auth data: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
119
firka/ios/FirkaWatch Watch App/Views/GradeSubjectView.swift
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
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 normalizedValue = grade.normalizedNumericValue {
|
||||||
|
if grade.isPercentageGrade, let rawValue = grade.numericValue {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(gradeColor(normalizedValue))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
Text("\(rawValue)%")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GradeBadge(grade: normalizedValue)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(grade.displayValue)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.padding(6)
|
||||||
|
.background(Color.gray)
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(grade.displayTypeWithWeight)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
|
||||||
|
if let topic = grade.topic, !topic.isEmpty {
|
||||||
|
Text(topic)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func gradeColor(_ value: Int) -> Color {
|
||||||
|
switch value {
|
||||||
|
case 5: return .green
|
||||||
|
case 4: return .blue
|
||||||
|
case 3: return .yellow
|
||||||
|
case 2: return .orange
|
||||||
|
default: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
598
firka/ios/FirkaWatch Watch App/Views/HomeView.swift
Normal file
@@ -0,0 +1,598 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WatchConnectivity
|
||||||
|
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
|
||||||
|
@State private var wasLoadingFromBackground: Bool = false
|
||||||
|
@State private var lastUpdateTime: Date? = nil
|
||||||
|
|
||||||
|
enum RefreshStatus {
|
||||||
|
case idle, loading, success, failure
|
||||||
|
}
|
||||||
|
|
||||||
|
private var refreshButton: some View {
|
||||||
|
Button(action: {
|
||||||
|
guard !dataStore.isLoading else { return }
|
||||||
|
Task {
|
||||||
|
refreshStatus = .loading
|
||||||
|
await dataStore.refreshAllWithRecovery()
|
||||||
|
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) {
|
||||||
|
if dataStore.isLoading && refreshStatus != .loading {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
} else {
|
||||||
|
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(dataStore.isLoading || refreshStatus == .loading)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.onChange(of: dataStore.isLoading) { oldValue, newValue in
|
||||||
|
if newValue && refreshStatus != .loading {
|
||||||
|
wasLoadingFromBackground = true
|
||||||
|
}
|
||||||
|
if !newValue && wasLoadingFromBackground && refreshStatus != .loading {
|
||||||
|
wasLoadingFromBackground = false
|
||||||
|
if dataStore.error == nil && dataStore.data != nil {
|
||||||
|
refreshStatus = .success
|
||||||
|
} else if dataStore.error != nil {
|
||||||
|
refreshStatus = .failure
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
if refreshStatus == .success || refreshStatus == .failure {
|
||||||
|
refreshStatus = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: dataStore.lastUpdated) { oldValue, newValue in
|
||||||
|
guard let oldValue, let newValue else { return }
|
||||||
|
guard newValue > oldValue else { return }
|
||||||
|
guard dataStore.error == nil else { return }
|
||||||
|
guard refreshStatus != .loading else { return }
|
||||||
|
|
||||||
|
refreshStatus = .success
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
if refreshStatus == .success {
|
||||||
|
refreshStatus = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var refreshStatusText: String {
|
||||||
|
if dataStore.isLoading && refreshStatus != .loading {
|
||||||
|
return "refreshing".localized
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
backgroundColor: lessonCardBackgroundColor(
|
||||||
|
for: lesson,
|
||||||
|
isHighlighted: true
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
lessonTitleWithStatus(
|
||||||
|
lesson,
|
||||||
|
font: .subheadline,
|
||||||
|
weight: .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(backgroundColor: lessonCardBackgroundColor(for: next)) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
lessonTitleWithStatus(
|
||||||
|
next,
|
||||||
|
font: .subheadline,
|
||||||
|
weight: .regular,
|
||||||
|
lineLimit: 2
|
||||||
|
)
|
||||||
|
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(ceil(next.start.timeIntervalSince(now) / 60)))
|
||||||
|
let totalBreakMinutes: Int = {
|
||||||
|
guard let previous = previousLesson else { return max(remaining, 1) }
|
||||||
|
let breakSeconds = max(60, next.start.timeIntervalSince(previous.end))
|
||||||
|
return max(1, Int(ceil(breakSeconds / 60)))
|
||||||
|
}()
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
CountdownRing(
|
||||||
|
totalMinutes: totalBreakMinutes,
|
||||||
|
remainingMinutes: remaining,
|
||||||
|
label: "minutes".localized,
|
||||||
|
size: 56,
|
||||||
|
lineWidth: 6,
|
||||||
|
displayOffset: 1
|
||||||
|
)
|
||||||
|
.id("break-\(next.start.timeIntervalSince1970)")
|
||||||
|
|
||||||
|
FirkaCard(backgroundColor: lessonCardBackgroundColor(for: next)) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(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(backgroundColor: lessonCardBackgroundColor(for: first)) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
lessonTitleWithStatus(
|
||||||
|
first,
|
||||||
|
font: .headline,
|
||||||
|
weight: .regular,
|
||||||
|
lineLimit: 2
|
||||||
|
)
|
||||||
|
|
||||||
|
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(backgroundColor: lessonCardBackgroundColor(for: nextLesson)) {
|
||||||
|
HStack {
|
||||||
|
lessonTitleWithStatus(
|
||||||
|
nextLesson,
|
||||||
|
font: .subheadline,
|
||||||
|
weight: .regular,
|
||||||
|
lineLimit: 2
|
||||||
|
)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func lessonTitleWithStatus(
|
||||||
|
_ lesson: WidgetLesson,
|
||||||
|
font: Font,
|
||||||
|
weight: Font.Weight = .regular,
|
||||||
|
lineLimit: Int = 2
|
||||||
|
) -> some View {
|
||||||
|
Text(lesson.displayName)
|
||||||
|
.font(font)
|
||||||
|
.fontWeight(weight)
|
||||||
|
.lineLimit(lineLimit)
|
||||||
|
.foregroundColor(lessonPrimaryTextColor(for: lesson))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lessonPrimaryTextColor(for lesson: WidgetLesson) -> Color {
|
||||||
|
if lesson.isCancelled {
|
||||||
|
return .red
|
||||||
|
}
|
||||||
|
if lesson.isSubstitution {
|
||||||
|
return .yellow
|
||||||
|
}
|
||||||
|
return .primary
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lessonCardBackgroundColor(
|
||||||
|
for lesson: WidgetLesson,
|
||||||
|
isHighlighted: Bool = false
|
||||||
|
) -> Color {
|
||||||
|
if lesson.isCancelled {
|
||||||
|
return Color.red.opacity(0.16)
|
||||||
|
}
|
||||||
|
if lesson.isSubstitution {
|
||||||
|
return Color.yellow.opacity(0.16)
|
||||||
|
}
|
||||||
|
if isHighlighted {
|
||||||
|
return Color.green.opacity(0.2)
|
||||||
|
}
|
||||||
|
return Color(white: 0.12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 isWatchSystemPaired: Bool {
|
||||||
|
guard WCSession.isSupported() else { return false }
|
||||||
|
return WCSession.default.isCompanionAppInstalled
|
||||||
|
}
|
||||||
|
|
||||||
|
private var noTokenTitleKey: String {
|
||||||
|
isWatchSystemPaired ? "login_on_iphone" : "pair_with_iphone"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var noTokenDescriptionKey: String {
|
||||||
|
isWatchSystemPaired ? "open_and_login_on_iphone" : "open_firka_on_iphone"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var noTokenIconName: String {
|
||||||
|
isWatchSystemPaired
|
||||||
|
? "person.crop.circle.badge.exclamationmark"
|
||||||
|
: "iphone.and.arrow.right.inward"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var noTokenView: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: noTokenIconName)
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
|
||||||
|
Text(noTokenTitleKey.localized)
|
||||||
|
.font(.headline)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text(noTokenDescriptionKey.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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
308
firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
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 with retries...")
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
_ = try await KretaAPIClient.shared.getValidToken()
|
||||||
|
print("[Watch] Token refresh succeeded! Now sending to iPhone...")
|
||||||
|
await MainActor.run {
|
||||||
|
self.sendRefreshedTokenToiPhone()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("[Watch] Token refresh failed after all retries: \(error)")
|
||||||
|
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...")
|
||||||
|
|
||||||
|
var 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)
|
||||||
|
]
|
||||||
|
if let tokenVersion = token.effectiveTokenVersion {
|
||||||
|
tokenData["tokenVersion"] = tokenVersion
|
||||||
|
}
|
||||||
|
tokenData["updatedAtMs"] = token.effectiveUpdatedAtMs ?? Int64(Date().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 {
|
||||||
|
func parseInt64(_ value: Any?) -> Int64? {
|
||||||
|
if let value = value as? Int64 { return value }
|
||||||
|
if let value = value as? Int { return Int64(value) }
|
||||||
|
if let value = value as? Double { return Int64(value) }
|
||||||
|
if let value = value as? String, let parsed = Int64(value) { return parsed }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let incomingSentAtMs = parseInt64(authDict["sentAtMs"]) ?? 0
|
||||||
|
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)
|
||||||
|
let currentToken = TokenManager.shared.loadToken()
|
||||||
|
let shouldForceAccountSwitch: Bool
|
||||||
|
if incomingSentAtMs > 0,
|
||||||
|
let currentToken,
|
||||||
|
!token.isSameAccount(as: currentToken) {
|
||||||
|
shouldForceAccountSwitch = true
|
||||||
|
} else {
|
||||||
|
shouldForceAccountSwitch = false
|
||||||
|
}
|
||||||
|
|
||||||
|
try TokenManager.shared.saveToken(
|
||||||
|
token,
|
||||||
|
syncToSharedKeychain: false,
|
||||||
|
forceAccountSwitch: shouldForceAccountSwitch
|
||||||
|
)
|
||||||
|
|
||||||
|
DataStore.shared.checkTokenState()
|
||||||
|
DataStore.shared.clearError()
|
||||||
|
|
||||||
|
print("[Watch] Token saved via reauth sync")
|
||||||
|
} catch {
|
||||||
|
print("[Watch] Failed to process auth data: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ReauthRequiredView()
|
||||||
|
}
|
||||||
78
firka/ios/FirkaWatch Watch App/Views/SettingsView.swift
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@AppStorage("refreshInterval") private var refreshInterval: Int = 0
|
||||||
|
@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("auto".localized).tag(0)
|
||||||
|
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()
|
||||||
|
_ = SharedSessionStateManager.shared.publishState(
|
||||||
|
hasAnyAccount: false,
|
||||||
|
activeStudentIdNorm: nil
|
||||||
|
)
|
||||||
|
DataStore.shared.clearAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
369
firka/ios/FirkaWatch Watch App/Views/TimetableView.swift
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
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 {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(lesson.displayName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
if let statusIcon = lessonStatusIconName(for: lesson) {
|
||||||
|
Image(systemName: statusIcon)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(lessonStatusColor(for: lesson))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lessonStatusIconName(for lesson: WidgetLesson) -> String? {
|
||||||
|
if lesson.isCancelled {
|
||||||
|
return "xmark.circle.fill"
|
||||||
|
}
|
||||||
|
if lesson.isSubstitution {
|
||||||
|
return "exclamationmark.circle.fill"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lessonStatusColor(for lesson: WidgetLesson) -> Color {
|
||||||
|
lesson.isCancelled ? .red : .yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
struct TimetableView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
TimetableView(dataStore: DataStore.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -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
@@ -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
|
||||||
12
firka/ios/FirkaWatchComplicationsExtension.entitlements
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?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>
|
||||||
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
|
<string>$(TeamIdentifierPrefix)app.firka.firkaa</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
12
firka/ios/HomeWidgetsExtension.entitlements
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?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>
|
||||||
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
|
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
45
firka/ios/HomeWidgetsExtension/AveragesWidget.swift
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AveragesWidget: Widget {
|
||||||
|
let kind: String = "AveragesWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
AppIntentConfiguration(
|
||||||
|
kind: kind,
|
||||||
|
intent: AveragesWidgetIntent.self,
|
||||||
|
provider: AveragesProvider()
|
||||||
|
) { entry in
|
||||||
|
AveragesWidgetView(entry: entry)
|
||||||
|
.containerBackground(.clear, for: .widget)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(LocalizedStringResource("widget_averages_title", defaultValue: "Averages"))
|
||||||
|
.description(LocalizedStringResource("widget_averages_description", defaultValue: "Shows subject averages"))
|
||||||
|
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AveragesWidgetView: View {
|
||||||
|
@Environment(\.widgetFamily) var family
|
||||||
|
let entry: AveragesEntry
|
||||||
|
|
||||||
|
var localization: WidgetLocalization {
|
||||||
|
WidgetLocalization(locale: entry.locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch family {
|
||||||
|
case .systemSmall:
|
||||||
|
AveragesSmallView(entry: entry, localization: localization)
|
||||||
|
case .systemMedium:
|
||||||
|
AveragesMediumView(entry: entry, localization: localization)
|
||||||
|
case .systemLarge:
|
||||||
|
AveragesLargeView(entry: entry, localization: localization)
|
||||||
|
default:
|
||||||
|
AveragesMediumView(entry: entry, localization: localization)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.widgetURL(URL(string: "firka://widget/grades"))
|
||||||
|
}
|
||||||
|
}
|
||||||
94
firka/ios/HomeWidgetsExtension/Controls/AppControls.swift
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
import AppIntents
|
||||||
|
|
||||||
|
private let appGroup = "group.app.firka.firkaa"
|
||||||
|
|
||||||
|
// MARK: - Navigation Intents (iOS 16+, used by Controls and Shortcuts)
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct OpenHomeIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = LocalizedStringResource("control_home_title", defaultValue: "Firka Home")
|
||||||
|
static var description: IntentDescription = IntentDescription(LocalizedStringResource("control_home_description", defaultValue: "Open Firka home screen"))
|
||||||
|
static var openAppWhenRun: Bool = true
|
||||||
|
|
||||||
|
func perform() async throws -> some IntentResult {
|
||||||
|
UserDefaults(suiteName: appGroup)?.set("home", forKey: "controlNavigation")
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct OpenGradesIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = LocalizedStringResource("control_grades_title", defaultValue: "Firka Grades")
|
||||||
|
static var description: IntentDescription = IntentDescription(LocalizedStringResource("control_grades_description", defaultValue: "Open Firka grades"))
|
||||||
|
static var openAppWhenRun: Bool = true
|
||||||
|
|
||||||
|
func perform() async throws -> some IntentResult {
|
||||||
|
UserDefaults(suiteName: appGroup)?.set("grades", forKey: "controlNavigation")
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct OpenTimetableIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = LocalizedStringResource("control_timetable_title", defaultValue: "Firka Timetable")
|
||||||
|
static var description: IntentDescription = IntentDescription(LocalizedStringResource("control_timetable_description", defaultValue: "Open Firka timetable"))
|
||||||
|
static var openAppWhenRun: Bool = true
|
||||||
|
|
||||||
|
func perform() async throws -> some IntentResult {
|
||||||
|
UserDefaults(suiteName: appGroup)?.set("timetable", forKey: "controlNavigation")
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Home Control (iOS 18+)
|
||||||
|
|
||||||
|
@available(iOS 18.0, *)
|
||||||
|
struct HomeControl: ControlWidget {
|
||||||
|
static let kind = "app.firka.firkaa.control.home"
|
||||||
|
|
||||||
|
var body: some ControlWidgetConfiguration {
|
||||||
|
StaticControlConfiguration(kind: Self.kind) {
|
||||||
|
ControlWidgetButton(action: OpenHomeIntent()) {
|
||||||
|
Label(LocalizedStringResource("control_home_label", defaultValue: "Home"), systemImage: "house.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.displayName(LocalizedStringResource("control_home_display", defaultValue: "Firka - Home"))
|
||||||
|
.description(LocalizedStringResource("control_home_description", defaultValue: "Open Firka home screen"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Grades Control (iOS 18+)
|
||||||
|
|
||||||
|
@available(iOS 18.0, *)
|
||||||
|
struct GradesControl: ControlWidget {
|
||||||
|
static let kind = "app.firka.firkaa.control.grades"
|
||||||
|
|
||||||
|
var body: some ControlWidgetConfiguration {
|
||||||
|
StaticControlConfiguration(kind: Self.kind) {
|
||||||
|
ControlWidgetButton(action: OpenGradesIntent()) {
|
||||||
|
Label(LocalizedStringResource("control_grades_label", defaultValue: "Grades"), systemImage: "star.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.displayName(LocalizedStringResource("control_grades_display", defaultValue: "Firka - Grades"))
|
||||||
|
.description(LocalizedStringResource("control_grades_description", defaultValue: "Open Firka grades"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timetable Control (iOS 18+)
|
||||||
|
|
||||||
|
@available(iOS 18.0, *)
|
||||||
|
struct TimetableControl: ControlWidget {
|
||||||
|
static let kind = "app.firka.firkaa.control.timetable"
|
||||||
|
|
||||||
|
var body: some ControlWidgetConfiguration {
|
||||||
|
StaticControlConfiguration(kind: Self.kind) {
|
||||||
|
ControlWidgetButton(action: OpenTimetableIntent()) {
|
||||||
|
Label(LocalizedStringResource("control_timetable_label", defaultValue: "Timetable"), systemImage: "calendar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.displayName(LocalizedStringResource("control_timetable_display", defaultValue: "Firka - Timetable"))
|
||||||
|
.description(LocalizedStringResource("control_timetable_description", defaultValue: "Open Firka timetable"))
|
||||||
|
}
|
||||||
|
}
|
||||||
45
firka/ios/HomeWidgetsExtension/GradesWidget.swift
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GradesWidget: Widget {
|
||||||
|
let kind: String = "GradesWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
AppIntentConfiguration(
|
||||||
|
kind: kind,
|
||||||
|
intent: GradesWidgetIntent.self,
|
||||||
|
provider: GradesProvider()
|
||||||
|
) { entry in
|
||||||
|
GradesWidgetView(entry: entry)
|
||||||
|
.containerBackground(.clear, for: .widget)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(LocalizedStringResource("widget_grades_title", defaultValue: "Recent Grades"))
|
||||||
|
.description(LocalizedStringResource("widget_grades_description", defaultValue: "Shows your recent grades"))
|
||||||
|
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GradesWidgetView: View {
|
||||||
|
@Environment(\.widgetFamily) var family
|
||||||
|
let entry: GradesEntry
|
||||||
|
|
||||||
|
var localization: WidgetLocalization {
|
||||||
|
WidgetLocalization(locale: entry.locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch family {
|
||||||
|
case .systemSmall:
|
||||||
|
GradesSmallView(entry: entry, localization: localization)
|
||||||
|
case .systemMedium:
|
||||||
|
GradesMediumView(entry: entry, localization: localization)
|
||||||
|
case .systemLarge:
|
||||||
|
GradesLargeView(entry: entry, localization: localization)
|
||||||
|
default:
|
||||||
|
GradesMediumView(entry: entry, localization: localization)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.widgetURL(URL(string: "firka://widget/grades"))
|
||||||
|
}
|
||||||
|
}
|
||||||
273
firka/ios/HomeWidgetsExtension/Helpers/Localization.swift
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct WidgetLocalization {
|
||||||
|
let locale: String
|
||||||
|
|
||||||
|
init(locale: String = "hu") {
|
||||||
|
self.locale = locale
|
||||||
|
}
|
||||||
|
|
||||||
|
private var translations: [String: [String: String]] {
|
||||||
|
[
|
||||||
|
"today_timetable": [
|
||||||
|
"hu": "Mai órarend",
|
||||||
|
"en": "Today's timetable",
|
||||||
|
"de": "Stundenplan heute"
|
||||||
|
],
|
||||||
|
"tomorrow_timetable": [
|
||||||
|
"hu": "Holnapi órarend",
|
||||||
|
"en": "Tomorrow's timetable",
|
||||||
|
"de": "Stundenplan morgen"
|
||||||
|
],
|
||||||
|
"next_school_day_timetable": [
|
||||||
|
"hu": "Következő órarend (%@)",
|
||||||
|
"en": "Next timetable (%@)",
|
||||||
|
"de": "Nächster Stundenplan (%@)"
|
||||||
|
],
|
||||||
|
"no_lessons_ahead": [
|
||||||
|
"hu": "Nincs óra a héten",
|
||||||
|
"en": "No lessons this week",
|
||||||
|
"de": "Kein Unterricht diese Woche"
|
||||||
|
],
|
||||||
|
"current_lesson": [
|
||||||
|
"hu": "Jelenlegi óra",
|
||||||
|
"en": "Current lesson",
|
||||||
|
"de": "Aktuelle Stunde"
|
||||||
|
],
|
||||||
|
"next_lesson": [
|
||||||
|
"hu": "Következő óra",
|
||||||
|
"en": "Next lesson",
|
||||||
|
"de": "Nächste Stunde"
|
||||||
|
],
|
||||||
|
"recent_grades": [
|
||||||
|
"hu": "Legutóbbi jegyek",
|
||||||
|
"en": "Recent grades",
|
||||||
|
"de": "Letzte Noten"
|
||||||
|
],
|
||||||
|
"subject_averages": [
|
||||||
|
"hu": "Tantárgyi átlagok",
|
||||||
|
"en": "Subject averages",
|
||||||
|
"de": "Fachdurchschnitte"
|
||||||
|
],
|
||||||
|
"overall_average": [
|
||||||
|
"hu": "Tanulmányi átlag",
|
||||||
|
"en": "Overall average",
|
||||||
|
"de": "Gesamtdurchschnitt"
|
||||||
|
],
|
||||||
|
"no_lessons": [
|
||||||
|
"hu": "Nincs több óra ma",
|
||||||
|
"en": "No more lessons today",
|
||||||
|
"de": "Keine Stunden mehr heute"
|
||||||
|
],
|
||||||
|
"no_grades": [
|
||||||
|
"hu": "Még nincsenek jegyeid",
|
||||||
|
"en": "No grades yet",
|
||||||
|
"de": "Noch keine Noten"
|
||||||
|
],
|
||||||
|
"no_averages": [
|
||||||
|
"hu": "Még nincsenek átlagok",
|
||||||
|
"en": "No averages yet",
|
||||||
|
"de": "Noch keine Durchschnitte"
|
||||||
|
],
|
||||||
|
"login_required": [
|
||||||
|
"hu": "Jelentkezz be újra",
|
||||||
|
"en": "Please log in again",
|
||||||
|
"de": "Bitte erneut anmelden"
|
||||||
|
],
|
||||||
|
"timetable_unavailable": [
|
||||||
|
"hu": "Az órarend még nem elérhető",
|
||||||
|
"en": "Timetable not available yet",
|
||||||
|
"de": "Stundenplan noch nicht verfügbar"
|
||||||
|
],
|
||||||
|
"happy_break": [
|
||||||
|
"hu": "Kellemes %@ szünetet!",
|
||||||
|
"en": "Happy %@ break!",
|
||||||
|
"de": "Schöne %@ Ferien!"
|
||||||
|
],
|
||||||
|
"days_remaining": [
|
||||||
|
"hu": "Még %d nap",
|
||||||
|
"en": "%d days left",
|
||||||
|
"de": "Noch %d Tage"
|
||||||
|
],
|
||||||
|
"break_autumn": [
|
||||||
|
"hu": "őszi",
|
||||||
|
"en": "autumn",
|
||||||
|
"de": "Herbst"
|
||||||
|
],
|
||||||
|
"break_winter": [
|
||||||
|
"hu": "téli",
|
||||||
|
"en": "winter",
|
||||||
|
"de": "Winter"
|
||||||
|
],
|
||||||
|
"break_spring": [
|
||||||
|
"hu": "tavaszi",
|
||||||
|
"en": "spring",
|
||||||
|
"de": "Frühlings"
|
||||||
|
],
|
||||||
|
"break_summer": [
|
||||||
|
"hu": "nyári",
|
||||||
|
"en": "summer",
|
||||||
|
"de": "Sommer"
|
||||||
|
],
|
||||||
|
"room": [
|
||||||
|
"hu": "Terem",
|
||||||
|
"en": "Room",
|
||||||
|
"de": "Raum"
|
||||||
|
],
|
||||||
|
"until": [
|
||||||
|
"hu": "eddig:",
|
||||||
|
"en": "until",
|
||||||
|
"de": "bis"
|
||||||
|
],
|
||||||
|
"no_more_lessons_today": [
|
||||||
|
"hu": "Ma már nincs több óra",
|
||||||
|
"en": "No more lessons today",
|
||||||
|
"de": "Keine Stunden mehr heute"
|
||||||
|
],
|
||||||
|
"tomorrow": [
|
||||||
|
"hu": "Holnap",
|
||||||
|
"en": "Tomorrow",
|
||||||
|
"de": "Morgen"
|
||||||
|
],
|
||||||
|
"tomorrow_short": [
|
||||||
|
"hu": "holnap",
|
||||||
|
"en": "tmrw",
|
||||||
|
"de": "morgen"
|
||||||
|
],
|
||||||
|
"next": [
|
||||||
|
"hu": "Következő",
|
||||||
|
"en": "Next",
|
||||||
|
"de": "Nächste"
|
||||||
|
],
|
||||||
|
"minutes_short": [
|
||||||
|
"hu": "perc",
|
||||||
|
"en": "min",
|
||||||
|
"de": "Min"
|
||||||
|
],
|
||||||
|
"lesson_short": [
|
||||||
|
"hu": "óra",
|
||||||
|
"en": "lesson",
|
||||||
|
"de": "Std"
|
||||||
|
],
|
||||||
|
"break_between": [
|
||||||
|
"hu": "Szünet",
|
||||||
|
"en": "Break",
|
||||||
|
"de": "Pause"
|
||||||
|
],
|
||||||
|
"in_minutes": [
|
||||||
|
"hu": "%d perc múlva",
|
||||||
|
"en": "in %d min",
|
||||||
|
"de": "in %d Min"
|
||||||
|
],
|
||||||
|
"today_new_grades": [
|
||||||
|
"hu": "Ma: %d új jegy",
|
||||||
|
"en": "Today: %d new",
|
||||||
|
"de": "Heute: %d neue"
|
||||||
|
],
|
||||||
|
"latest": [
|
||||||
|
"hu": "Legutóbbi",
|
||||||
|
"en": "Latest",
|
||||||
|
"de": "Letzte"
|
||||||
|
],
|
||||||
|
"today_grades": [
|
||||||
|
"hu": "Mai jegyek",
|
||||||
|
"en": "Today's grades",
|
||||||
|
"de": "Heutige Noten"
|
||||||
|
],
|
||||||
|
"pieces": [
|
||||||
|
"hu": "%d db",
|
||||||
|
"en": "%d pcs",
|
||||||
|
"de": "%d Stk"
|
||||||
|
],
|
||||||
|
"latest_grade": [
|
||||||
|
"hu": "Legutóbbi jegy",
|
||||||
|
"en": "Latest grade",
|
||||||
|
"de": "Letzte Note"
|
||||||
|
],
|
||||||
|
"average_short": [
|
||||||
|
"hu": "Átlag",
|
||||||
|
"en": "Avg",
|
||||||
|
"de": "Durchschn."
|
||||||
|
],
|
||||||
|
"overall_average_title": [
|
||||||
|
"hu": "Összesített átlag",
|
||||||
|
"en": "Overall average",
|
||||||
|
"de": "Gesamtdurchschnitt"
|
||||||
|
],
|
||||||
|
"subjects_count": [
|
||||||
|
"hu": "%d tárgy",
|
||||||
|
"en": "%d subjects",
|
||||||
|
"de": "%d Fächer"
|
||||||
|
],
|
||||||
|
"subject_averages_title": [
|
||||||
|
"hu": "Tantárgy átlagok",
|
||||||
|
"en": "Subject averages",
|
||||||
|
"de": "Fachdurchschnitte"
|
||||||
|
],
|
||||||
|
"subject_short": [
|
||||||
|
"hu": "tárgy",
|
||||||
|
"en": "subj",
|
||||||
|
"de": "Fächer"
|
||||||
|
],
|
||||||
|
"minutes_abbrev": [
|
||||||
|
"hu": "p",
|
||||||
|
"en": "min",
|
||||||
|
"de": "Min"
|
||||||
|
],
|
||||||
|
"hours_abbrev": [
|
||||||
|
"hu": "óra",
|
||||||
|
"en": "h",
|
||||||
|
"de": "Std"
|
||||||
|
],
|
||||||
|
"in_hours": [
|
||||||
|
"hu": "%d óra múlva",
|
||||||
|
"en": "in %d h",
|
||||||
|
"de": "in %d Std"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
func string(_ key: String) -> String {
|
||||||
|
translations[key]?[locale] ?? translations[key]?["hu"] ?? key
|
||||||
|
}
|
||||||
|
|
||||||
|
func string(_ key: String, _ arg: String) -> String {
|
||||||
|
let template = string(key)
|
||||||
|
return template.replacingOccurrences(of: "%@", with: arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func string(_ key: String, _ arg: Int) -> String {
|
||||||
|
let template = string(key)
|
||||||
|
return template.replacingOccurrences(of: "%d", with: "\(arg)")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func formatShortDate(_ isoString: String?, locale: String = "hu") -> String {
|
||||||
|
guard let isoString = isoString else { return "" }
|
||||||
|
|
||||||
|
let isoFormatter = ISO8601DateFormatter()
|
||||||
|
isoFormatter.formatOptions = [.withInternetDateTime]
|
||||||
|
|
||||||
|
let shortFormatter = DateFormatter()
|
||||||
|
shortFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||||
|
shortFormatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
|
||||||
|
let date: Date?
|
||||||
|
if let d = isoFormatter.date(from: isoString) {
|
||||||
|
date = d
|
||||||
|
} else if let d = shortFormatter.date(from: isoString) {
|
||||||
|
date = d
|
||||||
|
} else {
|
||||||
|
let simple = DateFormatter()
|
||||||
|
simple.dateFormat = "yyyy-MM-dd"
|
||||||
|
simple.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
date = simple.date(from: String(isoString.prefix(10)))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let date = date else { return "" }
|
||||||
|
|
||||||
|
let displayFormatter = DateFormatter()
|
||||||
|
displayFormatter.locale = Locale(identifier: locale == "de" ? "de_DE" : locale == "en" ? "en_US" : "hu_HU")
|
||||||
|
displayFormatter.dateFormat = locale == "hu" ? "MMM d." : "MMM d"
|
||||||
|
return displayFormatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
firka/ios/HomeWidgetsExtension/HomeWidgetsBundle.swift
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct HomeWidgetsBundle: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
// Home Screen Widgets
|
||||||
|
TimetableWidget()
|
||||||
|
GradesWidget()
|
||||||
|
AveragesWidget()
|
||||||
|
|
||||||
|
// Lock Screen Widgets (circular & rectangular)
|
||||||
|
TimetableLockScreenWidget()
|
||||||
|
GradesLockScreenWidget()
|
||||||
|
AveragesLockScreenWidget()
|
||||||
|
|
||||||
|
// Inline Widgets (above the clock)
|
||||||
|
TimetableInlineWidget()
|
||||||
|
GradesInlineWidget()
|
||||||
|
AveragesInlineWidget()
|
||||||
|
|
||||||
|
// Control Widgets (iOS 18+ Control Center & Lock Screen buttons)
|
||||||
|
if #available(iOS 18.0, *) {
|
||||||
|
HomeControl()
|
||||||
|
GradesControl()
|
||||||
|
TimetableControl()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
firka/ios/HomeWidgetsExtension/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>
|
||||||
13
firka/ios/HomeWidgetsExtension/Intents/AveragesIntent.swift
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import AppIntents
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
struct AveragesWidgetIntent: WidgetConfigurationIntent {
|
||||||
|
static var title: LocalizedStringResource = LocalizedStringResource("widget_averages_title", defaultValue: "Averages")
|
||||||
|
static var description: IntentDescription = IntentDescription(LocalizedStringResource("widget_averages_description", defaultValue: "Shows subject averages"))
|
||||||
|
|
||||||
|
@Parameter(title: LocalizedStringResource("param_style", defaultValue: "Style"), default: .liquidGlass)
|
||||||
|
var style: WidgetStyle?
|
||||||
|
|
||||||
|
@Parameter(title: LocalizedStringResource("param_subjects", defaultValue: "Subjects"))
|
||||||
|
var selectedSubjects: [SubjectEntity]?
|
||||||
|
}
|
||||||
10
firka/ios/HomeWidgetsExtension/Intents/GradesIntent.swift
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import AppIntents
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
struct GradesWidgetIntent: WidgetConfigurationIntent {
|
||||||
|
static var title: LocalizedStringResource = LocalizedStringResource("widget_grades_title", defaultValue: "Recent Grades")
|
||||||
|
static var description: IntentDescription = IntentDescription(LocalizedStringResource("widget_grades_description", defaultValue: "Shows your recent grades"))
|
||||||
|
|
||||||
|
@Parameter(title: LocalizedStringResource("param_style", defaultValue: "Style"), default: .liquidGlass)
|
||||||
|
var style: WidgetStyle?
|
||||||
|
}
|
||||||
45
firka/ios/HomeWidgetsExtension/Intents/TimetableIntent.swift
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import AppIntents
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
enum TimetableDisplayMode: String, AppEnum {
|
||||||
|
case current = "current"
|
||||||
|
case next = "next"
|
||||||
|
|
||||||
|
static var typeDisplayRepresentation: TypeDisplayRepresentation {
|
||||||
|
TypeDisplayRepresentation(name: LocalizedStringResource("display_mode_type", defaultValue: "Display Mode"))
|
||||||
|
}
|
||||||
|
|
||||||
|
static var caseDisplayRepresentations: [TimetableDisplayMode: DisplayRepresentation] {
|
||||||
|
[
|
||||||
|
.current: DisplayRepresentation(title: LocalizedStringResource("display_mode_current", defaultValue: "Current Lesson")),
|
||||||
|
.next: DisplayRepresentation(title: LocalizedStringResource("display_mode_next", defaultValue: "Next Lesson"))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WidgetStyle: String, AppEnum {
|
||||||
|
case liquidGlass = "liquid_glass"
|
||||||
|
case appTheme = "app_theme"
|
||||||
|
|
||||||
|
static var typeDisplayRepresentation: TypeDisplayRepresentation {
|
||||||
|
TypeDisplayRepresentation(name: LocalizedStringResource("style_type", defaultValue: "Style"))
|
||||||
|
}
|
||||||
|
|
||||||
|
static var caseDisplayRepresentations: [WidgetStyle: DisplayRepresentation] {
|
||||||
|
[
|
||||||
|
.liquidGlass: DisplayRepresentation(title: LocalizedStringResource("style_liquid_glass", defaultValue: "Liquid Glass")),
|
||||||
|
.appTheme: DisplayRepresentation(title: LocalizedStringResource("style_app_theme", defaultValue: "App Theme"))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TimetableWidgetIntent: WidgetConfigurationIntent {
|
||||||
|
static var title: LocalizedStringResource = LocalizedStringResource("widget_timetable_title", defaultValue: "Timetable")
|
||||||
|
static var description: IntentDescription = IntentDescription(LocalizedStringResource("widget_timetable_description", defaultValue: "Shows your daily timetable"))
|
||||||
|
|
||||||
|
@Parameter(title: LocalizedStringResource("param_style", defaultValue: "Style"), default: .liquidGlass)
|
||||||
|
var style: WidgetStyle?
|
||||||
|
|
||||||
|
@Parameter(title: LocalizedStringResource("param_display_mode_small", defaultValue: "Small Widget Display"), default: .current)
|
||||||
|
var displayMode: TimetableDisplayMode?
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Lock Screen Averages Widget
|
||||||
|
|
||||||
|
struct AveragesLockScreenWidget: Widget {
|
||||||
|
let kind: String = "AveragesLockScreenWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
AppIntentConfiguration(
|
||||||
|
kind: kind,
|
||||||
|
intent: AveragesWidgetIntent.self,
|
||||||
|
provider: AveragesProvider()
|
||||||
|
) { entry in
|
||||||
|
AveragesLockScreenView(entry: entry)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(LocalizedStringResource("widget_averages_title", defaultValue: "Averages"))
|
||||||
|
.description(LocalizedStringResource("widget_averages_lockscreen_description", defaultValue: "Shows your averages on lock screen"))
|
||||||
|
.supportedFamilies([.accessoryCircular, .accessoryRectangular])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lock Screen View
|
||||||
|
|
||||||
|
struct AveragesLockScreenView: View {
|
||||||
|
@Environment(\.widgetFamily) var family
|
||||||
|
let entry: AveragesEntry
|
||||||
|
|
||||||
|
var localization: WidgetLocalization {
|
||||||
|
WidgetLocalization(locale: entry.locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch family {
|
||||||
|
case .accessoryInline:
|
||||||
|
AveragesInlineView(entry: entry, localization: localization)
|
||||||
|
case .accessoryCircular:
|
||||||
|
AveragesCircularView(entry: entry, localization: localization)
|
||||||
|
case .accessoryRectangular:
|
||||||
|
AveragesRectangularView(entry: entry, localization: localization)
|
||||||
|
default:
|
||||||
|
Text("--")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.containerBackground(.clear, for: .widget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Inline View
|
||||||
|
|
||||||
|
struct AveragesInlineView: View {
|
||||||
|
let entry: AveragesEntry
|
||||||
|
let localization: WidgetLocalization
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let overall = entry.overallAverage {
|
||||||
|
Text("\(localization.string("average_short")): \(String(format: "%.2f", overall))")
|
||||||
|
} else if let first = entry.subjectAverages.first {
|
||||||
|
Text("\(first.name): \(String(format: "%.2f", first.average))")
|
||||||
|
} else {
|
||||||
|
Text(localization.string("no_averages"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Circular View
|
||||||
|
|
||||||
|
struct AveragesCircularView: View {
|
||||||
|
let entry: AveragesEntry
|
||||||
|
let localization: WidgetLocalization
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let overall = entry.overallAverage {
|
||||||
|
Gauge(value: overall, in: 1...5) {
|
||||||
|
Text("")
|
||||||
|
} currentValueLabel: {
|
||||||
|
Text(String(format: "%.1f", overall))
|
||||||
|
.font(.system(.title2, design: .rounded, weight: .bold))
|
||||||
|
.foregroundStyle(averageColor(overall))
|
||||||
|
}
|
||||||
|
.gaugeStyle(.accessoryCircularCapacity)
|
||||||
|
.tint(averageColor(overall))
|
||||||
|
} else if let first = entry.subjectAverages.first {
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text(String(format: "%.1f", first.average))
|
||||||
|
.font(.system(.title2, design: .rounded, weight: .bold))
|
||||||
|
.foregroundStyle(averageColor(first.average))
|
||||||
|
Text(String(first.name.prefix(4)))
|
||||||
|
.font(.system(.caption2))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
Image(systemName: "chart.bar")
|
||||||
|
.font(.title2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func averageColor(_ value: Double) -> Color {
|
||||||
|
switch value {
|
||||||
|
case 4.5...: return .green
|
||||||
|
case 3.5..<4.5: return .blue
|
||||||
|
case 2.5..<3.5: return .yellow
|
||||||
|
case 1.5..<2.5: return .orange
|
||||||
|
default: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rectangular View
|
||||||
|
|
||||||
|
struct AveragesRectangularView: View {
|
||||||
|
let entry: AveragesEntry
|
||||||
|
let localization: WidgetLocalization
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let overall = entry.overallAverage {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(String(format: "%.2f", overall))
|
||||||
|
.font(.system(.title, design: .rounded, weight: .bold))
|
||||||
|
.foregroundStyle(averageColor(overall))
|
||||||
|
.fixedSize()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(localization.string("average_short"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(localization.string("subjects_count", entry.subjectAverages.count))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else if !entry.subjectAverages.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(localization.string("subject_averages_title"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(entry.subjectAverages.prefix(3), id: \.uid) { subject in
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(String(format: "%.1f", subject.average))
|
||||||
|
.font(.system(.subheadline, design: .rounded, weight: .bold))
|
||||||
|
.foregroundStyle(averageColor(subject.average))
|
||||||
|
Text(String(subject.name.prefix(5)))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Label(localization.string("no_averages"), systemImage: "chart.bar")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func averageColor(_ value: Double) -> Color {
|
||||||
|
switch value {
|
||||||
|
case 4.5...: return .green
|
||||||
|
case 3.5..<4.5: return .blue
|
||||||
|
case 2.5..<3.5: return .yellow
|
||||||
|
case 1.5..<2.5: return .orange
|
||||||
|
default: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Lock Screen Grades Widget
|
||||||
|
|
||||||
|
struct GradesLockScreenWidget: Widget {
|
||||||
|
let kind: String = "GradesLockScreenWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
AppIntentConfiguration(
|
||||||
|
kind: kind,
|
||||||
|
intent: GradesWidgetIntent.self,
|
||||||
|
provider: GradesProvider()
|
||||||
|
) { entry in
|
||||||
|
GradesLockScreenView(entry: entry)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(LocalizedStringResource("widget_grades_title", defaultValue: "Recent Grades"))
|
||||||
|
.description(LocalizedStringResource("widget_grades_lockscreen_description", defaultValue: "Shows recent grades on lock screen"))
|
||||||
|
.supportedFamilies([.accessoryCircular, .accessoryRectangular])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lock Screen View
|
||||||
|
|
||||||
|
struct GradesLockScreenView: View {
|
||||||
|
@Environment(\.widgetFamily) var family
|
||||||
|
let entry: GradesEntry
|
||||||
|
|
||||||
|
var localization: WidgetLocalization {
|
||||||
|
WidgetLocalization(locale: entry.locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch family {
|
||||||
|
case .accessoryInline:
|
||||||
|
GradesInlineView(entry: entry, localization: localization)
|
||||||
|
case .accessoryCircular:
|
||||||
|
GradesCircularView(entry: entry, localization: localization)
|
||||||
|
case .accessoryRectangular:
|
||||||
|
GradesRectangularView(entry: entry, localization: localization)
|
||||||
|
default:
|
||||||
|
Text("--")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.containerBackground(.clear, for: .widget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Inline View
|
||||||
|
|
||||||
|
struct GradesInlineView: View {
|
||||||
|
let entry: GradesEntry
|
||||||
|
let localization: WidgetLocalization
|
||||||
|
|
||||||
|
var todayGrades: [WidgetGrade] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
return entry.grades.filter { calendar.startOfDay(for: $0.recordDate) == today }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let latest = entry.grades.first {
|
||||||
|
if todayGrades.count > 0 {
|
||||||
|
Text(localization.string("today_new_grades", todayGrades.count))
|
||||||
|
} else {
|
||||||
|
Text("\(localization.string("latest")): \(latest.displayValue) \(latest.subject.name)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(localization.string("no_grades"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Circular View
|
||||||
|
|
||||||
|
struct GradesCircularView: View {
|
||||||
|
let entry: GradesEntry
|
||||||
|
let localization: WidgetLocalization
|
||||||
|
|
||||||
|
var todayGradesCount: Int {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
return entry.grades.filter { calendar.startOfDay(for: $0.recordDate) == today }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let latest = entry.grades.first {
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
Text(latest.displayValue)
|
||||||
|
.font(.system(.title, design: .rounded, weight: .bold))
|
||||||
|
.foregroundStyle(gradeColor(latest.numericValue))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
Image(systemName: "graduationcap")
|
||||||
|
.font(.title2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func gradeColor(_ value: Int?) -> Color {
|
||||||
|
guard let value = value else { return .primary }
|
||||||
|
switch value {
|
||||||
|
case 5: return .green
|
||||||
|
case 4: return .blue
|
||||||
|
case 3: return .yellow
|
||||||
|
case 2: return .orange
|
||||||
|
case 1: return .red
|
||||||
|
default: return .primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rectangular View
|
||||||
|
|
||||||
|
struct GradesRectangularView: View {
|
||||||
|
let entry: GradesEntry
|
||||||
|
let localization: WidgetLocalization
|
||||||
|
|
||||||
|
var todayGrades: [WidgetGrade] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
return entry.grades.filter { calendar.startOfDay(for: $0.recordDate) == today }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !entry.grades.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
if todayGrades.count > 0 {
|
||||||
|
HStack {
|
||||||
|
Text(localization.string("today_grades"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(localization.string("pieces", todayGrades.count))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
ForEach(todayGrades.prefix(5), id: \.uid) { grade in
|
||||||
|
GradeBadge(grade: grade)
|
||||||
|
}
|
||||||
|
if todayGrades.count > 5 {
|
||||||
|
Text("+\(todayGrades.count - 5)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
} else if let latest = entry.grades.first {
|
||||||
|
HStack {
|
||||||
|
Text(localization.string("latest_grade"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(formatDate(latest.recordDate))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text(latest.displayValue)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundStyle(gradeColor(latest.numericValue))
|
||||||
|
Text(latest.subject.name)
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Label(localization.string("no_grades"), systemImage: "graduationcap")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDate(_ date: Date) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "MMM d."
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func gradeColor(_ value: Int?) -> Color {
|
||||||
|
guard let value = value else { return .primary }
|
||||||
|
switch value {
|
||||||
|
case 5: return .green
|
||||||
|
case 4: return .blue
|
||||||
|
case 3: return .yellow
|
||||||
|
case 2: return .orange
|
||||||
|
case 1: return .red
|
||||||
|
default: return .primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Grade Badge
|
||||||
|
|
||||||
|
struct GradeBadge: View {
|
||||||
|
let grade: WidgetGrade
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(grade.displayValue)
|
||||||
|
.font(.system(.caption, design: .rounded, weight: .bold))
|
||||||
|
.foregroundStyle(gradeColor(grade.numericValue))
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(gradeColor(grade.numericValue).opacity(0.2))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func gradeColor(_ value: Int?) -> Color {
|
||||||
|
guard let value = value else { return .primary }
|
||||||
|
switch value {
|
||||||
|
case 5: return .green
|
||||||
|
case 4: return .blue
|
||||||
|
case 3: return .yellow
|
||||||
|
case 2: return .orange
|
||||||
|
case 1: return .red
|
||||||
|
default: return .primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
154
firka/ios/HomeWidgetsExtension/LockScreen/InlineWidgets.swift
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Timetable Inline Widget
|
||||||
|
|
||||||
|
struct TimetableInlineWidget: Widget {
|
||||||
|
let kind: String = "TimetableInlineWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
AppIntentConfiguration(
|
||||||
|
kind: kind,
|
||||||
|
intent: TimetableWidgetIntent.self,
|
||||||
|
provider: TimetableProvider()
|
||||||
|
) { entry in
|
||||||
|
TimetableInlineWidgetView(entry: entry)
|
||||||
|
.containerBackground(.clear, for: .widget)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(LocalizedStringResource("widget_timetable_title", defaultValue: "Timetable"))
|
||||||
|
.description(LocalizedStringResource("widget_timetable_inline_description", defaultValue: "Shows next lesson above the clock"))
|
||||||
|
.supportedFamilies([.accessoryInline])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TimetableInlineWidgetView: View {
|
||||||
|
let entry: TimetableEntry
|
||||||
|
|
||||||
|
var localization: WidgetLocalization {
|
||||||
|
WidgetLocalization(locale: entry.data?.locale ?? "hu")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if entry.state == .onBreak, let breakInfo = entry.breakInfo {
|
||||||
|
Text(localization.string(breakInfo.nameKey))
|
||||||
|
} else if let current = entry.currentLesson {
|
||||||
|
let remaining = minutesRemaining(until: current.end)
|
||||||
|
Text("\(current.subject.name) · \(remaining) \(localization.string("minutes_abbrev"))")
|
||||||
|
} else if entry.isNextSchoolDay {
|
||||||
|
if let first = entry.lessons.first {
|
||||||
|
let dateStr = WidgetLocalization.formatShortDate(entry.nextSchoolDayDateString, locale: localization.locale)
|
||||||
|
let lessonNum = first.lessonNumber ?? 1
|
||||||
|
Text("\(dateStr): \(lessonNum). \(first.subject.name)")
|
||||||
|
} else {
|
||||||
|
Text(localization.string("no_lessons_ahead"))
|
||||||
|
}
|
||||||
|
} else if entry.isNextDay {
|
||||||
|
if let first = entry.lessons.first {
|
||||||
|
let lessonNum = first.lessonNumber ?? 1
|
||||||
|
Text("\(localization.string("tomorrow")): \(lessonNum). \(first.subject.name)")
|
||||||
|
} else {
|
||||||
|
Text(localization.string("no_lessons_ahead"))
|
||||||
|
}
|
||||||
|
} else if let next = entry.nextLesson {
|
||||||
|
let until = minutesRemaining(until: next.start)
|
||||||
|
if until <= 0 {
|
||||||
|
Text("→ \(next.subject.name)")
|
||||||
|
} else if until > 60 {
|
||||||
|
let hours = until / 60
|
||||||
|
Text("→ \(next.subject.name) · \(hours) \(localization.string("hours_abbrev"))")
|
||||||
|
} else {
|
||||||
|
Text("→ \(next.subject.name) · \(until) \(localization.string("minutes_abbrev"))")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(localization.string("no_lessons_ahead"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func minutesRemaining(until date: Date) -> Int {
|
||||||
|
let diff = date.timeIntervalSince(entry.date)
|
||||||
|
return max(0, Int(ceil(diff / 60)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Grades Inline Widget
|
||||||
|
|
||||||
|
struct GradesInlineWidget: Widget {
|
||||||
|
let kind: String = "GradesInlineWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
AppIntentConfiguration(
|
||||||
|
kind: kind,
|
||||||
|
intent: GradesWidgetIntent.self,
|
||||||
|
provider: GradesProvider()
|
||||||
|
) { entry in
|
||||||
|
GradesInlineWidgetView(entry: entry)
|
||||||
|
.containerBackground(.clear, for: .widget)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(LocalizedStringResource("widget_grades_title", defaultValue: "Grades"))
|
||||||
|
.description(LocalizedStringResource("widget_grades_inline_description", defaultValue: "Shows recent grades above the clock"))
|
||||||
|
.supportedFamilies([.accessoryInline])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GradesInlineWidgetView: View {
|
||||||
|
let entry: GradesEntry
|
||||||
|
|
||||||
|
var localization: WidgetLocalization {
|
||||||
|
WidgetLocalization(locale: entry.locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
var todayGrades: [WidgetGrade] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
return entry.grades.filter { calendar.startOfDay(for: $0.recordDate) == today }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if todayGrades.count > 0 {
|
||||||
|
Text("📝 \(localization.string("today_new_grades", todayGrades.count))")
|
||||||
|
} else if let latest = entry.grades.first {
|
||||||
|
// No grades today - show latest
|
||||||
|
Text("\(localization.string("latest")): \(latest.displayValue) \(latest.subject.name)")
|
||||||
|
} else {
|
||||||
|
Text(localization.string("no_grades"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Averages Inline Widget
|
||||||
|
|
||||||
|
struct AveragesInlineWidget: Widget {
|
||||||
|
let kind: String = "AveragesInlineWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
AppIntentConfiguration(
|
||||||
|
kind: kind,
|
||||||
|
intent: AveragesWidgetIntent.self,
|
||||||
|
provider: AveragesProvider()
|
||||||
|
) { entry in
|
||||||
|
AveragesInlineWidgetView(entry: entry)
|
||||||
|
.containerBackground(.clear, for: .widget)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(LocalizedStringResource("widget_averages_title", defaultValue: "Averages"))
|
||||||
|
.description(LocalizedStringResource("widget_averages_inline_description", defaultValue: "Shows your average above the clock"))
|
||||||
|
.supportedFamilies([.accessoryInline])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AveragesInlineWidgetView: View {
|
||||||
|
let entry: AveragesEntry
|
||||||
|
|
||||||
|
var localization: WidgetLocalization {
|
||||||
|
WidgetLocalization(locale: entry.locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let overall = entry.overallAverage {
|
||||||
|
Text("\(localization.string("average_short")): \(String(format: "%.2f", overall)) · \(entry.subjectAverages.count) \(localization.string("subject_short"))")
|
||||||
|
} else if let first = entry.subjectAverages.first {
|
||||||
|
Text("\(first.name): \(String(format: "%.2f", first.average))")
|
||||||
|
} else {
|
||||||
|
Text(localization.string("no_averages"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Lock Screen Timetable Widget
|
||||||
|
|
||||||
|
struct TimetableLockScreenWidget: Widget {
|
||||||
|
let kind: String = "TimetableLockScreenWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
AppIntentConfiguration(
|
||||||
|
kind: kind,
|
||||||
|
intent: TimetableWidgetIntent.self,
|
||||||
|
provider: TimetableProvider()
|
||||||
|
) { entry in
|
||||||
|
TimetableLockScreenView(entry: entry)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(LocalizedStringResource("widget_timetable_title", defaultValue: "Timetable"))
|
||||||
|
.description(LocalizedStringResource("widget_timetable_lockscreen_description", defaultValue: "Shows current lesson on lock screen"))
|
||||||
|
.supportedFamilies([.accessoryCircular, .accessoryRectangular])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lock Screen View
|
||||||
|
|
||||||
|
struct TimetableLockScreenView: View {
|
||||||
|
@Environment(\.widgetFamily) var family
|
||||||
|
let entry: TimetableEntry
|
||||||
|
|
||||||
|
var localization: WidgetLocalization {
|
||||||
|
WidgetLocalization(locale: entry.data?.locale ?? "hu")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch family {
|
||||||
|
case .accessoryInline:
|
||||||
|
TimetableInlineView(entry: entry, localization: localization)
|
||||||
|
case .accessoryCircular:
|
||||||
|
TimetableCircularView(entry: entry, localization: localization)
|
||||||
|
case .accessoryRectangular:
|
||||||
|
TimetableRectangularView(entry: entry, localization: localization)
|
||||||
|
default:
|
||||||
|
Text("--")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.containerBackground(.clear, for: .widget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Inline View (single line next to date)
|
||||||
|
|
||||||
|
struct TimetableInlineView: View {
|
||||||
|
let entry: TimetableEntry
|
||||||
|
let localization: WidgetLocalization
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if entry.state == .onBreak, let breakInfo = entry.breakInfo {
|
||||||
|
Text("🏖️ \(localization.string(breakInfo.nameKey))")
|
||||||
|
} else if let current = entry.currentLesson {
|
||||||
|
let remaining = minutesRemaining(until: current.end)
|
||||||
|
Text("\(current.subject.name) - \(remaining) \(localization.string("minutes_short"))")
|
||||||
|
} else if entry.isNextSchoolDay {
|
||||||
|
if let first = entry.lessons.first {
|
||||||
|
let dateStr = WidgetLocalization.formatShortDate(entry.nextSchoolDayDateString, locale: localization.locale)
|
||||||
|
let lessonNum = first.lessonNumber ?? 1
|
||||||
|
Text("\(dateStr): \(lessonNum). \(first.subject.name)")
|
||||||
|
} else {
|
||||||
|
Text(localization.string("no_lessons_ahead"))
|
||||||
|
}
|
||||||
|
} else if entry.isNextDay {
|
||||||
|
if let first = entry.lessons.first {
|
||||||
|
let lessonNum = first.lessonNumber ?? 1
|
||||||
|
Text("\(localization.string("tomorrow")): \(lessonNum). \(first.subject.name)")
|
||||||
|
} else {
|
||||||
|
Text(localization.string("no_lessons_ahead"))
|
||||||
|
}
|
||||||
|
} else if let next = entry.nextLesson {
|
||||||
|
let until = minutesRemaining(until: next.start)
|
||||||
|
if until <= 0 {
|
||||||
|
Text("\(localization.string("next")): \(next.subject.name)")
|
||||||
|
} else if until > 60 {
|
||||||
|
let hours = until / 60
|
||||||
|
Text("\(next.subject.name) \(localization.string("in_hours", hours))")
|
||||||
|
} else {
|
||||||
|
Text("\(next.subject.name) \(localization.string("in_minutes", until))")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(localization.string("no_lessons_ahead"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func minutesRemaining(until date: Date) -> Int {
|
||||||
|
let diff = date.timeIntervalSince(entry.date)
|
||||||
|
return max(0, Int(ceil(diff / 60)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Circular View (small circle)
|
||||||
|
|
||||||
|
struct TimetableCircularView: View {
|
||||||
|
let entry: TimetableEntry
|
||||||
|
let localization: WidgetLocalization
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if entry.state == .onBreak {
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
Image(systemName: "sun.max.fill")
|
||||||
|
.font(.title2)
|
||||||
|
}
|
||||||
|
} else if let current = entry.currentLesson {
|
||||||
|
let remaining = minutesRemaining(until: current.end)
|
||||||
|
Gauge(value: Double(remaining), in: 0...45) {
|
||||||
|
Text("")
|
||||||
|
} currentValueLabel: {
|
||||||
|
Text("\(remaining)")
|
||||||
|
.font(.system(.title2, design: .rounded, weight: .bold))
|
||||||
|
}
|
||||||
|
.gaugeStyle(.accessoryCircularCapacity)
|
||||||
|
} else if entry.isNextSchoolDay || entry.isNextDay {
|
||||||
|
let lessonCount = entry.lessons.count
|
||||||
|
if lessonCount > 0 {
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text("\(lessonCount)")
|
||||||
|
.font(.system(.title2, design: .rounded, weight: .bold))
|
||||||
|
Text(localization.string("lesson_short"))
|
||||||
|
.font(.system(.caption2))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
Image(systemName: "calendar.badge.checkmark")
|
||||||
|
.font(.title2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let next = entry.nextLesson {
|
||||||
|
let until = minutesRemaining(until: next.start)
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if until > 60 {
|
||||||
|
let hours = until / 60
|
||||||
|
Text("\(hours)")
|
||||||
|
.font(.system(.title2, design: .rounded, weight: .bold))
|
||||||
|
Text(localization.string("hours_abbrev"))
|
||||||
|
.font(.system(.caption2))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("\(until)")
|
||||||
|
.font(.system(.title2, design: .rounded, weight: .bold))
|
||||||
|
Text(localization.string("minutes_abbrev"))
|
||||||
|
.font(.system(.caption2))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let lesson = entry.lessons.first, let lessonNum = lesson.lessonNumber {
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text("\(lessonNum).")
|
||||||
|
.font(.system(.title2, design: .rounded, weight: .bold))
|
||||||
|
Text(localization.string("lesson_short"))
|
||||||
|
.font(.system(.caption2))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
Image(systemName: "calendar.badge.checkmark")
|
||||||
|
.font(.title2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func minutesRemaining(until date: Date) -> Int {
|
||||||
|
let diff = date.timeIntervalSince(entry.date)
|
||||||
|
return max(0, Int(ceil(diff / 60)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rectangular View (medium rectangle)
|
||||||
|
|
||||||
|
struct TimetableRectangularView: View {
|
||||||
|
let entry: TimetableEntry
|
||||||
|
let localization: WidgetLocalization
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if entry.state == .onBreak, let breakInfo = entry.breakInfo {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Label(localization.string(breakInfo.nameKey), systemImage: "sun.max.fill")
|
||||||
|
.font(.headline)
|
||||||
|
Text(localization.string("until") + " " + formatDate(breakInfo.endDate))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else if let current = entry.currentLesson {
|
||||||
|
let remaining = minutesRemaining(until: current.end)
|
||||||
|
let lessonNum = current.lessonNumber ?? 0
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack {
|
||||||
|
Text("\(lessonNum). \(current.subject.name)")
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
Text("\(remaining)'")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
if let room = current.roomName {
|
||||||
|
Label(room, systemImage: "mappin")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(formatTimeRange(current.start, current.end))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else if let next = entry.nextLesson {
|
||||||
|
let until = minutesRemaining(until: next.start)
|
||||||
|
let isFutureDay = entry.isNextDay || entry.isNextSchoolDay
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack {
|
||||||
|
if entry.isNextSchoolDay {
|
||||||
|
let dateStr = WidgetLocalization.formatShortDate(entry.nextSchoolDayDateString, locale: localization.locale)
|
||||||
|
Text(dateStr)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text(entry.isNextDay ? localization.string("tomorrow") : localization.string("next"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if until > 0 && !isFutureDay {
|
||||||
|
if until > 60 {
|
||||||
|
Text(localization.string("in_hours", until / 60))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text(localization.string("in_minutes", until))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("\(next.lessonNumber ?? 0). \(next.subject.name)")
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(1)
|
||||||
|
HStack {
|
||||||
|
if let room = next.roomName {
|
||||||
|
Label(room, systemImage: "mappin")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(formatTimeRange(next.start, next.end))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Label(localization.string("no_lessons"), systemImage: "checkmark.circle")
|
||||||
|
.font(.headline)
|
||||||
|
Text(localization.string("no_more_lessons_today"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func minutesRemaining(until date: Date) -> Int {
|
||||||
|
let diff = date.timeIntervalSince(entry.date)
|
||||||
|
return max(0, Int(ceil(diff / 60)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatTimeRange(_ start: Date, _ end: Date) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "HH:mm"
|
||||||
|
return "\(formatter.string(from: start)) - \(formatter.string(from: end))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDate(_ date: Date) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "MMM d."
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AveragesEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let configuration: AveragesWidgetIntent
|
||||||
|
let overallAverage: Double?
|
||||||
|
let subjectAverages: [SubjectAverage]
|
||||||
|
let locale: String
|
||||||
|
let isFiltered: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AveragesProvider: AppIntentTimelineProvider {
|
||||||
|
typealias Entry = AveragesEntry
|
||||||
|
typealias Intent = AveragesWidgetIntent
|
||||||
|
|
||||||
|
func placeholder(in context: Context) -> AveragesEntry {
|
||||||
|
AveragesEntry(
|
||||||
|
date: Date(),
|
||||||
|
configuration: AveragesWidgetIntent(),
|
||||||
|
overallAverage: nil,
|
||||||
|
subjectAverages: [],
|
||||||
|
locale: "hu",
|
||||||
|
isFiltered: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(for configuration: AveragesWidgetIntent, in context: Context) async -> AveragesEntry {
|
||||||
|
createEntry(for: configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeline(for configuration: AveragesWidgetIntent, in context: Context) async -> Timeline<AveragesEntry> {
|
||||||
|
let entry = createEntry(for: configuration)
|
||||||
|
|
||||||
|
// Refresh every 30 minutes
|
||||||
|
let refreshDate = Date().addingTimeInterval(30 * 60)
|
||||||
|
return Timeline(entries: [entry], policy: .after(refreshDate))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createEntry(for configuration: AveragesWidgetIntent) -> AveragesEntry {
|
||||||
|
let data = WidgetData.load()
|
||||||
|
|
||||||
|
var subjectAverages = data?.averages.subjects ?? []
|
||||||
|
let isFiltered = configuration.selectedSubjects?.isEmpty == false
|
||||||
|
|
||||||
|
if isFiltered {
|
||||||
|
let selectedIds = Set(configuration.selectedSubjects!.map { $0.id })
|
||||||
|
subjectAverages = subjectAverages.filter { selectedIds.contains($0.uid) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return AveragesEntry(
|
||||||
|
date: Date(),
|
||||||
|
configuration: configuration,
|
||||||
|
overallAverage: data?.averages.overall,
|
||||||
|
subjectAverages: subjectAverages,
|
||||||
|
locale: data?.locale ?? "hu",
|
||||||
|
isFiltered: isFiltered
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GradesEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let configuration: GradesWidgetIntent
|
||||||
|
let grades: [WidgetGrade]
|
||||||
|
let locale: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GradesProvider: AppIntentTimelineProvider {
|
||||||
|
typealias Entry = GradesEntry
|
||||||
|
typealias Intent = GradesWidgetIntent
|
||||||
|
|
||||||
|
func placeholder(in context: Context) -> GradesEntry {
|
||||||
|
GradesEntry(date: Date(), configuration: GradesWidgetIntent(), grades: [], locale: "hu")
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(for configuration: GradesWidgetIntent, in context: Context) async -> GradesEntry {
|
||||||
|
let data = WidgetData.load()
|
||||||
|
return GradesEntry(
|
||||||
|
date: Date(),
|
||||||
|
configuration: configuration,
|
||||||
|
grades: data?.grades ?? [],
|
||||||
|
locale: data?.locale ?? "hu"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeline(for configuration: GradesWidgetIntent, in context: Context) async -> Timeline<GradesEntry> {
|
||||||
|
let data = WidgetData.load()
|
||||||
|
let entry = GradesEntry(
|
||||||
|
date: Date(),
|
||||||
|
configuration: configuration,
|
||||||
|
grades: data?.grades ?? [],
|
||||||
|
locale: data?.locale ?? "hu"
|
||||||
|
)
|
||||||
|
|
||||||
|
let refreshDate = Date().addingTimeInterval(30 * 60)
|
||||||
|
return Timeline(entries: [entry], policy: .after(refreshDate))
|
||||||
|
}
|
||||||
|
}
|
||||||
384
firka/ios/HomeWidgetsExtension/Providers/TimetableProvider.swift
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TimetableEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let configuration: TimetableWidgetIntent
|
||||||
|
let data: WidgetData?
|
||||||
|
let lessons: [WidgetLesson]
|
||||||
|
let currentLesson: WidgetLesson?
|
||||||
|
let nextLesson: WidgetLesson?
|
||||||
|
let isNextDay: Bool
|
||||||
|
let isNextSchoolDay: Bool
|
||||||
|
let nextSchoolDayDateString: String?
|
||||||
|
let breakInfo: BreakInfo?
|
||||||
|
let state: TimetableState
|
||||||
|
let debugInfo: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TimetableState {
|
||||||
|
case normal
|
||||||
|
case noMoreLessons
|
||||||
|
case onBreak
|
||||||
|
case loginRequired
|
||||||
|
case unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TimetableProvider: AppIntentTimelineProvider {
|
||||||
|
typealias Entry = TimetableEntry
|
||||||
|
typealias Intent = TimetableWidgetIntent
|
||||||
|
|
||||||
|
private struct LessonCandidate {
|
||||||
|
let lessons: [WidgetLesson]
|
||||||
|
let day: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
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 static let isoFormatterWithFractional: ISO8601DateFormatter = {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let isoFormatter: ISO8601DateFormatter = {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func parseNextSchoolDayDate(_ dateString: String?) -> Date? {
|
||||||
|
guard let dateString = dateString else { return nil }
|
||||||
|
if let date = Self.isoFormatterWithFractional.date(from: dateString) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
if let date = Self.isoFormatter.date(from: dateString) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
if let date = Self.dateFormatter.date(from: dateString) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = String(dateString.prefix(10))
|
||||||
|
return Self.dateFormatter.date(from: trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startOfDay(for lessons: [WidgetLesson], calendar: Calendar) -> Date? {
|
||||||
|
guard let first = lessons.first else { return nil }
|
||||||
|
return calendar.startOfDay(for: first.start)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nextSchoolDay(from data: WidgetData, calendar: Calendar) -> Date? {
|
||||||
|
if let firstNextLesson = data.timetable.nextSchoolDay?.first {
|
||||||
|
return calendar.startOfDay(for: firstNextLesson.start)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let parsedDate = parseNextSchoolDayDate(data.timetable.nextSchoolDayDate) {
|
||||||
|
return calendar.startOfDay(for: parsedDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeholder(in context: Context) -> TimetableEntry {
|
||||||
|
TimetableEntry(
|
||||||
|
date: Date(),
|
||||||
|
configuration: TimetableWidgetIntent(),
|
||||||
|
data: nil,
|
||||||
|
lessons: [],
|
||||||
|
currentLesson: nil,
|
||||||
|
nextLesson: nil,
|
||||||
|
isNextDay: false,
|
||||||
|
isNextSchoolDay: false,
|
||||||
|
nextSchoolDayDateString: nil,
|
||||||
|
breakInfo: nil,
|
||||||
|
state: .normal,
|
||||||
|
debugInfo: "placeholder"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(for configuration: TimetableWidgetIntent, in context: Context) async -> TimetableEntry {
|
||||||
|
createEntry(for: configuration, date: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeline(for configuration: TimetableWidgetIntent, in context: Context) async -> Timeline<TimetableEntry> {
|
||||||
|
var entries: [TimetableEntry] = []
|
||||||
|
let now = Date()
|
||||||
|
let calendar = Calendar.current
|
||||||
|
|
||||||
|
let data = WidgetData.load()
|
||||||
|
|
||||||
|
// If on break, create single entry
|
||||||
|
if let breakInfo = data?.timetable.currentBreak {
|
||||||
|
let entry = TimetableEntry(
|
||||||
|
date: now,
|
||||||
|
configuration: configuration,
|
||||||
|
data: data,
|
||||||
|
lessons: [],
|
||||||
|
currentLesson: nil,
|
||||||
|
nextLesson: nil,
|
||||||
|
isNextDay: false,
|
||||||
|
isNextSchoolDay: false,
|
||||||
|
nextSchoolDayDateString: nil,
|
||||||
|
breakInfo: breakInfo,
|
||||||
|
state: .onBreak,
|
||||||
|
debugInfo: WidgetData.lastError
|
||||||
|
)
|
||||||
|
entries.append(entry)
|
||||||
|
return Timeline(entries: entries, policy: .after(calendar.startOfDay(for: now.addingTimeInterval(86400))))
|
||||||
|
}
|
||||||
|
|
||||||
|
let todayLessons = data?.timetable.today ?? []
|
||||||
|
|
||||||
|
entries.append(createEntry(for: configuration, date: now))
|
||||||
|
|
||||||
|
let currentLesson = todayLessons.first { now >= $0.start && now <= $0.end }
|
||||||
|
let nextLesson = todayLessons.first { $0.start > now }
|
||||||
|
|
||||||
|
let isLockScreenWidget = context.family == .accessoryInline ||
|
||||||
|
context.family == .accessoryCircular ||
|
||||||
|
context.family == .accessoryRectangular
|
||||||
|
|
||||||
|
if isLockScreenWidget {
|
||||||
|
var minuteEntries: [Date] = []
|
||||||
|
|
||||||
|
if let current = currentLesson {
|
||||||
|
var time = now.addingTimeInterval(60)
|
||||||
|
while time <= current.end && minuteEntries.count < 60 {
|
||||||
|
minuteEntries.append(time)
|
||||||
|
time = time.addingTimeInterval(60)
|
||||||
|
}
|
||||||
|
minuteEntries.append(current.end.addingTimeInterval(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let next = nextLesson {
|
||||||
|
let minutesUntilNext = next.start.timeIntervalSince(now) / 60
|
||||||
|
|
||||||
|
if minutesUntilNext <= 60 {
|
||||||
|
var time = currentLesson?.end.addingTimeInterval(60) ?? now.addingTimeInterval(60)
|
||||||
|
while time < next.start && minuteEntries.count < 120 {
|
||||||
|
minuteEntries.append(time)
|
||||||
|
time = time.addingTimeInterval(60)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let sixtyMinutesBefore = next.start.addingTimeInterval(-60 * 60)
|
||||||
|
if sixtyMinutesBefore > now {
|
||||||
|
minuteEntries.append(sixtyMinutesBefore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
minuteEntries.append(next.start)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if time > now {
|
||||||
|
entries.append(createEntry(for: configuration, date: time))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for lesson in todayLessons {
|
||||||
|
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 tomorrowLessons = data?.timetable.tomorrow ?? []
|
||||||
|
for lesson in tomorrowLessons {
|
||||||
|
if lesson.start > now {
|
||||||
|
entries.append(createEntry(for: configuration, date: lesson.start))
|
||||||
|
}
|
||||||
|
if lesson.end > now {
|
||||||
|
entries.append(createEntry(for: configuration, date: lesson.end.addingTimeInterval(1)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextSchoolDayLessons = data?.timetable.nextSchoolDay ?? []
|
||||||
|
for lesson in nextSchoolDayLessons {
|
||||||
|
if lesson.start > now {
|
||||||
|
entries.append(createEntry(for: configuration, date: lesson.start))
|
||||||
|
}
|
||||||
|
if lesson.end > now {
|
||||||
|
entries.append(createEntry(for: configuration, date: lesson.end.addingTimeInterval(1)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let midnight = calendar.startOfDay(for: now.addingTimeInterval(86400))
|
||||||
|
entries.append(createEntry(for: configuration, date: midnight))
|
||||||
|
|
||||||
|
if let data = data,
|
||||||
|
let nextSchoolDay = nextSchoolDay(from: data, calendar: calendar) {
|
||||||
|
let dayBeforeNextSchoolDay = calendar.date(byAdding: .day, value: -1, to: nextSchoolDay)!
|
||||||
|
|
||||||
|
if dayBeforeNextSchoolDay > now {
|
||||||
|
entries.append(createEntry(for: configuration, date: dayBeforeNextSchoolDay))
|
||||||
|
}
|
||||||
|
|
||||||
|
if nextSchoolDay > now {
|
||||||
|
entries.append(createEntry(for: configuration, date: nextSchoolDay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let uniqueDates = Set(entries.map { $0.date })
|
||||||
|
entries = uniqueDates.map { date in
|
||||||
|
entries.first { $0.date == date }!
|
||||||
|
}
|
||||||
|
entries.sort { $0.date < $1.date }
|
||||||
|
|
||||||
|
if isLockScreenWidget {
|
||||||
|
var refreshDate: Date
|
||||||
|
if let current = currentLesson {
|
||||||
|
refreshDate = current.end.addingTimeInterval(1)
|
||||||
|
} else if let next = nextLesson {
|
||||||
|
refreshDate = next.end.addingTimeInterval(1)
|
||||||
|
} else {
|
||||||
|
refreshDate = midnight
|
||||||
|
}
|
||||||
|
return Timeline(entries: entries, policy: .after(refreshDate))
|
||||||
|
}
|
||||||
|
|
||||||
|
return Timeline(entries: entries, policy: .atEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createEntry(for configuration: TimetableWidgetIntent, date: Date) -> TimetableEntry {
|
||||||
|
let data = WidgetData.load()
|
||||||
|
let calendar = Calendar.current
|
||||||
|
|
||||||
|
guard let data = data else {
|
||||||
|
return TimetableEntry(
|
||||||
|
date: date,
|
||||||
|
configuration: configuration,
|
||||||
|
data: nil,
|
||||||
|
lessons: [],
|
||||||
|
currentLesson: nil,
|
||||||
|
nextLesson: nil,
|
||||||
|
isNextDay: false,
|
||||||
|
isNextSchoolDay: false,
|
||||||
|
nextSchoolDayDateString: nil,
|
||||||
|
breakInfo: nil,
|
||||||
|
state: .loginRequired,
|
||||||
|
debugInfo: WidgetData.lastError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let breakInfo = data.timetable.currentBreak {
|
||||||
|
return TimetableEntry(
|
||||||
|
date: date,
|
||||||
|
configuration: configuration,
|
||||||
|
data: data,
|
||||||
|
lessons: [],
|
||||||
|
currentLesson: nil,
|
||||||
|
nextLesson: nil,
|
||||||
|
isNextDay: false,
|
||||||
|
isNextSchoolDay: false,
|
||||||
|
nextSchoolDayDateString: nil,
|
||||||
|
breakInfo: breakInfo,
|
||||||
|
state: .onBreak,
|
||||||
|
debugInfo: WidgetData.lastError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let entryDay = calendar.startOfDay(for: date)
|
||||||
|
let tomorrowOfEntryDay = calendar.date(byAdding: .day, value: 1, to: entryDay)!
|
||||||
|
|
||||||
|
let todayLessons = data.timetable.today
|
||||||
|
let tomorrowLessons = data.timetable.tomorrow
|
||||||
|
let nextSchoolDayLessons = data.timetable.nextSchoolDay ?? []
|
||||||
|
|
||||||
|
var candidates: [LessonCandidate] = []
|
||||||
|
|
||||||
|
if let todayDay = startOfDay(for: todayLessons, calendar: calendar), !todayLessons.isEmpty {
|
||||||
|
candidates.append(LessonCandidate(lessons: todayLessons, day: todayDay))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let tomorrowDay = startOfDay(for: tomorrowLessons, calendar: calendar), !tomorrowLessons.isEmpty {
|
||||||
|
candidates.append(LessonCandidate(lessons: tomorrowLessons, day: tomorrowDay))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !nextSchoolDayLessons.isEmpty,
|
||||||
|
let resolvedNextSchoolDay = nextSchoolDay(from: data, calendar: calendar)
|
||||||
|
?? startOfDay(for: nextSchoolDayLessons, calendar: calendar) {
|
||||||
|
candidates.append(LessonCandidate(lessons: nextSchoolDayLessons, day: resolvedNextSchoolDay))
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort { $0.day < $1.day }
|
||||||
|
|
||||||
|
// Pick the closest candidate that still has lessons ahead relative to this entry date.
|
||||||
|
let selectedCandidate = candidates.first { candidate in
|
||||||
|
if candidate.day > entryDay {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if candidate.day == entryDay, let lastLesson = candidate.lessons.last {
|
||||||
|
return date <= lastLesson.end
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let selectedCandidate = selectedCandidate else {
|
||||||
|
let hadLessonsTodayButFinished = candidates.contains { candidate in
|
||||||
|
guard candidate.day == entryDay, let lastLesson = candidate.lessons.last else { return false }
|
||||||
|
return date > lastLesson.end
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimetableEntry(
|
||||||
|
date: date,
|
||||||
|
configuration: configuration,
|
||||||
|
data: data,
|
||||||
|
lessons: [],
|
||||||
|
currentLesson: nil,
|
||||||
|
nextLesson: nil,
|
||||||
|
isNextDay: false,
|
||||||
|
isNextSchoolDay: false,
|
||||||
|
nextSchoolDayDateString: nil,
|
||||||
|
breakInfo: nil,
|
||||||
|
state: hadLessonsTodayButFinished ? .noMoreLessons : .unavailable,
|
||||||
|
debugInfo: WidgetData.lastError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let lessons = selectedCandidate.lessons
|
||||||
|
let isToday = selectedCandidate.day == entryDay
|
||||||
|
let isNextDay = selectedCandidate.day == tomorrowOfEntryDay
|
||||||
|
let isNextSchoolDay = !isToday && !isNextDay
|
||||||
|
let nextSchoolDayDateString = isNextSchoolDay
|
||||||
|
? (data.timetable.nextSchoolDayDate ?? Self.dateFormatter.string(from: selectedCandidate.day))
|
||||||
|
: nil
|
||||||
|
|
||||||
|
let currentLesson = lessons.first { lesson in
|
||||||
|
return date >= lesson.start && date <= lesson.end
|
||||||
|
}
|
||||||
|
let nextLesson = lessons.first { $0.start > date }
|
||||||
|
|
||||||
|
return TimetableEntry(
|
||||||
|
date: date,
|
||||||
|
configuration: configuration,
|
||||||
|
data: data,
|
||||||
|
lessons: lessons,
|
||||||
|
currentLesson: currentLesson,
|
||||||
|
nextLesson: nextLesson,
|
||||||
|
isNextDay: isNextDay,
|
||||||
|
isNextSchoolDay: isNextSchoolDay,
|
||||||
|
nextSchoolDayDateString: nextSchoolDayDateString,
|
||||||
|
breakInfo: nil,
|
||||||
|
state: .normal,
|
||||||
|
debugInfo: WidgetData.lastError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import AppIntents
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct AverageEntity: AppEntity {
|
||||||
|
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: LocalizedStringResource("entity_average", defaultValue: "Average"))
|
||||||
|
static var defaultQuery = AverageEntityQuery()
|
||||||
|
|
||||||
|
var id: String
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_subject", defaultValue: "Subject"))
|
||||||
|
var subjectName: String
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_average", defaultValue: "Average"))
|
||||||
|
var average: Double
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_grade_count", defaultValue: "Grade count"))
|
||||||
|
var gradeCount: Int
|
||||||
|
|
||||||
|
var displayRepresentation: DisplayRepresentation {
|
||||||
|
DisplayRepresentation(
|
||||||
|
title: "\(subjectName)",
|
||||||
|
subtitle: "\(String(format: "%.2f", average)) (\(gradeCount))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from avg: SubjectAverage) {
|
||||||
|
self.id = avg.uid
|
||||||
|
self.subjectName = avg.name
|
||||||
|
self.average = avg.average
|
||||||
|
self.gradeCount = avg.gradeCount
|
||||||
|
}
|
||||||
|
|
||||||
|
init(id: String, subjectName: String, average: Double, gradeCount: Int) {
|
||||||
|
self.id = id
|
||||||
|
self.subjectName = subjectName
|
||||||
|
self.average = average
|
||||||
|
self.gradeCount = gradeCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct AverageEntityQuery: EntityQuery {
|
||||||
|
func entities(for identifiers: [String]) async throws -> [AverageEntity] {
|
||||||
|
guard let data = WidgetData.load() else { return [] }
|
||||||
|
return data.averages.subjects
|
||||||
|
.filter { identifiers.contains($0.uid) }
|
||||||
|
.map { AverageEntity(from: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggestedEntities() async throws -> [AverageEntity] {
|
||||||
|
guard let data = WidgetData.load() else { return [] }
|
||||||
|
return data.averages.subjects.map { AverageEntity(from: $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import AppIntents
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct GradeEntity: AppEntity {
|
||||||
|
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: LocalizedStringResource("entity_grade", defaultValue: "Grade"))
|
||||||
|
static var defaultQuery = GradeEntityQuery()
|
||||||
|
|
||||||
|
var id: String
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_subject", defaultValue: "Subject"))
|
||||||
|
var subject: String
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_value", defaultValue: "Value"))
|
||||||
|
var numericValue: Int
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_grade", defaultValue: "Grade"))
|
||||||
|
var strValue: String
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_topic", defaultValue: "Topic"))
|
||||||
|
var topic: String
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_type", defaultValue: "Type"))
|
||||||
|
var type: String
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_teacher", defaultValue: "Teacher"))
|
||||||
|
var teacher: String
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_date", defaultValue: "Date"))
|
||||||
|
var date: Date
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_weight", defaultValue: "Weight"))
|
||||||
|
var weight: Int
|
||||||
|
|
||||||
|
var displayRepresentation: DisplayRepresentation {
|
||||||
|
DisplayRepresentation(
|
||||||
|
title: "\(subject): \(strValue)",
|
||||||
|
subtitle: "\(topic)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from grade: WidgetGrade) {
|
||||||
|
self.id = grade.uid
|
||||||
|
self.subject = grade.subject.name
|
||||||
|
self.numericValue = grade.numericValue ?? 0
|
||||||
|
self.strValue = grade.displayValue
|
||||||
|
self.topic = grade.topic ?? ""
|
||||||
|
self.type = grade.type.name
|
||||||
|
self.teacher = grade.subject.teacherName ?? ""
|
||||||
|
self.date = grade.recordDate
|
||||||
|
self.weight = grade.weightPercentage ?? 100
|
||||||
|
}
|
||||||
|
|
||||||
|
init(id: String, subject: String, numericValue: Int, strValue: String, topic: String, type: String, teacher: String, date: Date, weight: Int) {
|
||||||
|
self.id = id
|
||||||
|
self.subject = subject
|
||||||
|
self.numericValue = numericValue
|
||||||
|
self.strValue = strValue
|
||||||
|
self.topic = topic
|
||||||
|
self.type = type
|
||||||
|
self.teacher = teacher
|
||||||
|
self.date = date
|
||||||
|
self.weight = weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct GradeEntityQuery: EntityQuery {
|
||||||
|
func entities(for identifiers: [String]) async throws -> [GradeEntity] {
|
||||||
|
guard let data = WidgetData.load() else { return [] }
|
||||||
|
return data.grades
|
||||||
|
.filter { identifiers.contains($0.uid) }
|
||||||
|
.map { GradeEntity(from: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggestedEntities() async throws -> [GradeEntity] {
|
||||||
|
guard let data = WidgetData.load() else { return [] }
|
||||||
|
return data.grades.prefix(10).map { GradeEntity(from: $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import AppIntents
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct LessonEntity: AppEntity {
|
||||||
|
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: LocalizedStringResource("entity_lesson", defaultValue: "Lesson"))
|
||||||
|
static var defaultQuery = LessonEntityQuery()
|
||||||
|
|
||||||
|
var id: String
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_name", defaultValue: "Name"))
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_teacher", defaultValue: "Teacher"))
|
||||||
|
var teacher: String
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_room", defaultValue: "Room"))
|
||||||
|
var room: String
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_start_time", defaultValue: "Start time"))
|
||||||
|
var startTime: Date
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_end_time", defaultValue: "End time"))
|
||||||
|
var endTime: Date
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_lesson_number", defaultValue: "Lesson number"))
|
||||||
|
var lessonNumber: Int
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_cancelled", defaultValue: "Cancelled"))
|
||||||
|
var isCancelled: Bool
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_substitution", defaultValue: "Substitution"))
|
||||||
|
var isSubstitution: Bool
|
||||||
|
|
||||||
|
@Property(title: LocalizedStringResource("entity_prop_theme", defaultValue: "Theme"))
|
||||||
|
var theme: String
|
||||||
|
|
||||||
|
var displayRepresentation: DisplayRepresentation {
|
||||||
|
DisplayRepresentation(
|
||||||
|
title: "\(name)",
|
||||||
|
subtitle: "\(lessonNumber). \(room) - \(teacher)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from lesson: WidgetLesson) {
|
||||||
|
self.id = lesson.uid
|
||||||
|
self.name = lesson.subject.name
|
||||||
|
self.teacher = lesson.teacher ?? ""
|
||||||
|
self.room = lesson.roomName ?? ""
|
||||||
|
self.startTime = lesson.start
|
||||||
|
self.endTime = lesson.end
|
||||||
|
self.lessonNumber = lesson.lessonNumber ?? 0
|
||||||
|
self.isCancelled = lesson.isCancelled
|
||||||
|
self.isSubstitution = lesson.isSubstitution
|
||||||
|
self.theme = lesson.theme ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
init(id: String, name: String, teacher: String, room: String, startTime: Date, endTime: Date, lessonNumber: Int, isCancelled: Bool, isSubstitution: Bool, theme: String) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.teacher = teacher
|
||||||
|
self.room = room
|
||||||
|
self.startTime = startTime
|
||||||
|
self.endTime = endTime
|
||||||
|
self.lessonNumber = lessonNumber
|
||||||
|
self.isCancelled = isCancelled
|
||||||
|
self.isSubstitution = isSubstitution
|
||||||
|
self.theme = theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct LessonEntityQuery: EntityQuery {
|
||||||
|
func entities(for identifiers: [String]) async throws -> [LessonEntity] {
|
||||||
|
guard let data = WidgetData.load() else { return [] }
|
||||||
|
let allLessons = data.timetable.today + data.timetable.tomorrow + (data.timetable.nextSchoolDay ?? [])
|
||||||
|
return allLessons
|
||||||
|
.filter { identifiers.contains($0.uid) }
|
||||||
|
.map { LessonEntity(from: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggestedEntities() async throws -> [LessonEntity] {
|
||||||
|
guard let data = WidgetData.load() else { return [] }
|
||||||
|
return data.timetable.today.map { LessonEntity(from: $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import AppIntents
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct SubjectEntity: AppEntity {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
|
||||||
|
static var typeDisplayRepresentation: TypeDisplayRepresentation {
|
||||||
|
TypeDisplayRepresentation(name: LocalizedStringResource("subjects_type", defaultValue: "Subjects"))
|
||||||
|
}
|
||||||
|
|
||||||
|
static var defaultQuery = SubjectQuery()
|
||||||
|
|
||||||
|
var displayRepresentation: DisplayRepresentation {
|
||||||
|
DisplayRepresentation(title: "\(name)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct SubjectQuery: EntityQuery {
|
||||||
|
func entities(for identifiers: [String]) async throws -> [SubjectEntity] {
|
||||||
|
let data = WidgetData.load()
|
||||||
|
return data?.averages.subjects
|
||||||
|
.filter { identifiers.contains($0.uid) }
|
||||||
|
.map { SubjectEntity(id: $0.uid, name: $0.name) } ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggestedEntities() async throws -> [SubjectEntity] {
|
||||||
|
let data = WidgetData.load()
|
||||||
|
return data?.averages.subjects
|
||||||
|
.map { SubjectEntity(id: $0.uid, name: $0.name) } ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultResult() async -> SubjectEntity? {
|
||||||
|
try? await suggestedEntities().first
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import AppIntents
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct FirkaShortcuts: AppShortcutsProvider {
|
||||||
|
static var appShortcuts: [AppShortcut] {
|
||||||
|
AppShortcut(
|
||||||
|
intent: GetNextLessonIntent(),
|
||||||
|
phrases: [
|
||||||
|
"Next lesson \(.applicationName)",
|
||||||
|
"What is my next lesson \(.applicationName)"
|
||||||
|
],
|
||||||
|
shortTitle: LocalizedStringResource("shortcut_short_next_lesson", defaultValue: "Next lesson"),
|
||||||
|
systemImageName: "calendar"
|
||||||
|
)
|
||||||
|
AppShortcut(
|
||||||
|
intent: GetClosestLessonIntent(),
|
||||||
|
phrases: [
|
||||||
|
"Closest lesson \(.applicationName)",
|
||||||
|
"When is my next lesson \(.applicationName)"
|
||||||
|
],
|
||||||
|
shortTitle: LocalizedStringResource("shortcut_short_closest_lesson", defaultValue: "Closest lesson"),
|
||||||
|
systemImageName: "forward"
|
||||||
|
)
|
||||||
|
AppShortcut(
|
||||||
|
intent: GetTodayTimetableIntent(),
|
||||||
|
phrases: [
|
||||||
|
"Today's timetable \(.applicationName)",
|
||||||
|
"What lessons do I have today \(.applicationName)"
|
||||||
|
],
|
||||||
|
shortTitle: LocalizedStringResource("shortcut_short_today_timetable", defaultValue: "Today's timetable"),
|
||||||
|
systemImageName: "clock"
|
||||||
|
)
|
||||||
|
AppShortcut(
|
||||||
|
intent: GetClosestTimetableIntent(),
|
||||||
|
phrases: [
|
||||||
|
"Closest timetable \(.applicationName)",
|
||||||
|
"When are my next lessons \(.applicationName)"
|
||||||
|
],
|
||||||
|
shortTitle: LocalizedStringResource("shortcut_short_closest_timetable", defaultValue: "Closest timetable"),
|
||||||
|
systemImageName: "forward.fill"
|
||||||
|
)
|
||||||
|
AppShortcut(
|
||||||
|
intent: GetOverallAverageIntent(),
|
||||||
|
phrases: [
|
||||||
|
"My average \(.applicationName)",
|
||||||
|
"What is my average \(.applicationName)"
|
||||||
|
],
|
||||||
|
shortTitle: LocalizedStringResource("shortcut_short_overall_average", defaultValue: "My average"),
|
||||||
|
systemImageName: "chart.bar"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import AppIntents
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct GetClosestLessonIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = LocalizedStringResource("shortcut_closest_lesson_title", defaultValue: "Closest lesson")
|
||||||
|
static var description: IntentDescription = IntentDescription(LocalizedStringResource("shortcut_closest_lesson_description", defaultValue: "Get the closest lesson (today, tomorrow, or next school day)"))
|
||||||
|
|
||||||
|
func perform() async throws -> some ReturnsValue<LessonEntity> & ProvidesDialog {
|
||||||
|
guard let data = WidgetData.load() else {
|
||||||
|
throw ShortcutError.noData
|
||||||
|
}
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
if let lesson = data.timetable.today.first(where: { $0.start > now })
|
||||||
|
?? data.timetable.today.first(where: { $0.end > now }) {
|
||||||
|
let entity = LessonEntity(from: lesson)
|
||||||
|
return .result(value: entity, dialog: "\(entity.name) - \(entity.room)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lesson = data.timetable.tomorrow.first {
|
||||||
|
let entity = LessonEntity(from: lesson)
|
||||||
|
return .result(value: entity, dialog: "\(entity.name) - \(entity.room)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lesson = data.timetable.nextSchoolDay?.first {
|
||||||
|
let entity = LessonEntity(from: lesson)
|
||||||
|
return .result(value: entity, dialog: "\(entity.name) - \(entity.room)")
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ShortcutError.noUpcomingLesson
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import AppIntents
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct GetClosestTimetableIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = LocalizedStringResource("shortcut_closest_timetable_title", defaultValue: "Closest timetable")
|
||||||
|
static var description: IntentDescription = IntentDescription(LocalizedStringResource("shortcut_closest_timetable_description", defaultValue: "Get the closest school day's timetable (today, tomorrow, or next school day)"))
|
||||||
|
|
||||||
|
func perform() async throws -> some ReturnsValue<[LessonEntity]> & ProvidesDialog {
|
||||||
|
guard let data = WidgetData.load() else {
|
||||||
|
throw ShortcutError.noData
|
||||||
|
}
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
let remaining = data.timetable.today.filter { $0.end > now }
|
||||||
|
if !remaining.isEmpty {
|
||||||
|
let entities = remaining.map { LessonEntity(from: $0) }
|
||||||
|
let summary = entities.map { "\($0.lessonNumber). \($0.name)" }.joined(separator: ", ")
|
||||||
|
return .result(value: entities, dialog: "\(summary)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !data.timetable.tomorrow.isEmpty {
|
||||||
|
let entities = data.timetable.tomorrow.map { LessonEntity(from: $0) }
|
||||||
|
let summary = entities.map { "\($0.lessonNumber). \($0.name)" }.joined(separator: ", ")
|
||||||
|
return .result(value: entities, dialog: "\(summary)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let nextDay = data.timetable.nextSchoolDay, !nextDay.isEmpty {
|
||||||
|
let entities = nextDay.map { LessonEntity(from: $0) }
|
||||||
|
let summary = entities.map { "\($0.lessonNumber). \($0.name)" }.joined(separator: ", ")
|
||||||
|
return .result(value: entities, dialog: "\(summary)")
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ShortcutError.noUpcomingLesson
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import AppIntents
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct GetNextLessonIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = LocalizedStringResource("shortcut_next_lesson_title", defaultValue: "Next lesson")
|
||||||
|
static var description: IntentDescription = IntentDescription(LocalizedStringResource("shortcut_next_lesson_description", defaultValue: "Get the next lesson details"))
|
||||||
|
|
||||||
|
func perform() async throws -> some ReturnsValue<LessonEntity> & ProvidesDialog {
|
||||||
|
guard let data = WidgetData.load() else {
|
||||||
|
throw ShortcutError.noData
|
||||||
|
}
|
||||||
|
let now = Date()
|
||||||
|
let upcoming = data.timetable.today.first { $0.start > now }
|
||||||
|
?? data.timetable.today.first { $0.end > now }
|
||||||
|
guard let lesson = upcoming else {
|
||||||
|
throw ShortcutError.noUpcomingLesson
|
||||||
|
}
|
||||||
|
let entity = LessonEntity(from: lesson)
|
||||||
|
return .result(value: entity, dialog: "\(entity.name) - \(entity.room)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import AppIntents
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct GetOverallAverageIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = LocalizedStringResource("shortcut_overall_average_title", defaultValue: "Overall average")
|
||||||
|
static var description: IntentDescription = IntentDescription(LocalizedStringResource("shortcut_overall_average_description", defaultValue: "Get the overall academic average"))
|
||||||
|
|
||||||
|
func perform() async throws -> some ReturnsValue<Double> & ProvidesDialog {
|
||||||
|
guard let data = WidgetData.load() else {
|
||||||
|
throw ShortcutError.noData
|
||||||
|
}
|
||||||
|
guard let overall = data.averages.overall else {
|
||||||
|
throw ShortcutError.noData
|
||||||
|
}
|
||||||
|
let rounded = (overall * 100).rounded() / 100
|
||||||
|
return .result(value: rounded, dialog: "\(String(format: "%.2f", rounded))")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import AppIntents
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct GetRecentGradesIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = LocalizedStringResource("shortcut_recent_grades_title", defaultValue: "Recent grades")
|
||||||
|
static var description: IntentDescription = IntentDescription(LocalizedStringResource("shortcut_recent_grades_description", defaultValue: "Get the most recent grades"))
|
||||||
|
|
||||||
|
@Parameter(title: LocalizedStringResource("shortcut_param_count", defaultValue: "Count"), default: 5)
|
||||||
|
var count: Int
|
||||||
|
|
||||||
|
func perform() async throws -> some ReturnsValue<[GradeEntity]> & ProvidesDialog {
|
||||||
|
guard let data = WidgetData.load() else {
|
||||||
|
throw ShortcutError.noData
|
||||||
|
}
|
||||||
|
let grades = Array(data.grades.prefix(count))
|
||||||
|
let entities = grades.map { GradeEntity(from: $0) }
|
||||||
|
let summary = entities.map { "\($0.subject): \($0.strValue)" }.joined(separator: ", ")
|
||||||
|
return .result(value: entities, dialog: "\(summary)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import AppIntents
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct GetSubjectAverageIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = LocalizedStringResource("shortcut_subject_average_title", defaultValue: "Subject average")
|
||||||
|
static var description: IntentDescription = IntentDescription(LocalizedStringResource("shortcut_subject_average_description", defaultValue: "Get the average for a subject"))
|
||||||
|
|
||||||
|
@Parameter(title: LocalizedStringResource("shortcut_param_subject", defaultValue: "Subject"))
|
||||||
|
var subject: SubjectEntity
|
||||||
|
|
||||||
|
func perform() async throws -> some ReturnsValue<AverageEntity> & ProvidesDialog {
|
||||||
|
guard let data = WidgetData.load() else {
|
||||||
|
throw ShortcutError.noData
|
||||||
|
}
|
||||||
|
guard let avg = data.averages.subjects.first(where: { $0.uid == subject.id }) else {
|
||||||
|
throw ShortcutError.subjectNotFound
|
||||||
|
}
|
||||||
|
let entity = AverageEntity(from: avg)
|
||||||
|
return .result(value: entity, dialog: "\(entity.subjectName): \(String(format: "%.2f", entity.average))")
|
||||||
|
}
|
||||||
|
}
|
||||||