Compare commits
44 Commits
dev
...
8f28fa328c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.
|
||||
|
||||
version:
|
||||
revision: "05db9689081f091050f01aed79f04dce0c750154"
|
||||
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
@@ -13,11 +13,11 @@ project_type: app
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 05db9689081f091050f01aed79f04dce0c750154
|
||||
base_revision: 05db9689081f091050f01aed79f04dce0c750154
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
- platform: ios
|
||||
create_revision: 05db9689081f091050f01aed79f04dce0c750154
|
||||
base_revision: 05db9689081f091050f01aed79f04dce0c750154
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
|
||||
# User provided section
|
||||
|
||||
|
||||
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"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
<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-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"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</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
|
||||
the Flutter engine draws its first frame -->
|
||||
<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>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
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
|
||||
the Flutter engine draws its first frame -->
|
||||
<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>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
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.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,59 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CountdownRing: View {
|
||||
let totalMinutes: Int
|
||||
let remainingMinutes: Int
|
||||
let label: String
|
||||
var size: CGFloat = 80
|
||||
var lineWidth: CGFloat = 8
|
||||
var displayOffset: Int = 0 // Add to displayed minutes (e.g., +1)
|
||||
|
||||
var progress: Double {
|
||||
guard totalMinutes > 0 else { return 0 }
|
||||
return Double(totalMinutes - remainingMinutes) / Double(totalMinutes)
|
||||
}
|
||||
|
||||
var displayedMinutes: Int {
|
||||
remainingMinutes + displayOffset
|
||||
}
|
||||
|
||||
var ringColor: Color {
|
||||
if remainingMinutes < 5 { return .red }
|
||||
if remainingMinutes < 10 { return .yellow }
|
||||
return .green
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color(white: 0.2), lineWidth: lineWidth)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(ringColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut, value: progress)
|
||||
|
||||
VStack(spacing: 1) {
|
||||
Text("\(displayedMinutes)")
|
||||
.font(size > 60 ? .title2 : .headline)
|
||||
.fontWeight(.bold)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 20) {
|
||||
CountdownRing(totalMinutes: 45, remainingMinutes: 30, label: "min")
|
||||
|
||||
CountdownRing(totalMinutes: 45, remainingMinutes: 8, label: "min")
|
||||
|
||||
CountdownRing(totalMinutes: 45, remainingMinutes: 3, label: "min")
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
33
firka/ios/FirkaWatch Watch App/Components/FirkaCard.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FirkaCard<Content: View>: View {
|
||||
let content: Content
|
||||
var isHighlighted: Bool = false
|
||||
|
||||
init(isHighlighted: Bool = false, @ViewBuilder content: () -> Content) {
|
||||
self.isHighlighted = isHighlighted
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(12)
|
||||
.background(isHighlighted ? Color.green.opacity(0.2) : Color(white: 0.12))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 12) {
|
||||
FirkaCard {
|
||||
Text("Normal Card")
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
FirkaCard(isHighlighted: true) {
|
||||
Text("Highlighted Card")
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
39
firka/ios/FirkaWatch Watch App/Components/GradeBadge.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
151
firka/ios/FirkaWatch Watch App/ContentView.swift
Normal file
@@ -0,0 +1,151 @@
|
||||
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.checkTokenState()
|
||||
dataStore.loadFromCache()
|
||||
if dataStore.hasToken {
|
||||
await dataStore.refreshAllWithRecovery()
|
||||
} else {
|
||||
requestToken()
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { oldPhase, newPhase in
|
||||
if newPhase == .active && oldPhase != .active {
|
||||
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
|
||||
if scenePhase == .active && 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)?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "iphone.and.arrow.right.inward")
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(.blue)
|
||||
|
||||
Text("pair_with_iphone".localized)
|
||||
.font(.headline)
|
||||
|
||||
Text("open_firka_on_iphone".localized)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
if WCSession.default.isReachable {
|
||||
Button("sync_button".localized) {
|
||||
onRequestToken?()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
370
firka/ios/FirkaWatch Watch App/Localization/WatchL10n.swift
Normal file
@@ -0,0 +1,370 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
enum WatchLanguage: String, CaseIterable, Codable {
|
||||
case hungarian = "hu"
|
||||
case english = "en"
|
||||
case german = "de"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .hungarian: return "Magyar"
|
||||
case .english: return "English"
|
||||
case .german: return "Deutsch"
|
||||
}
|
||||
}
|
||||
|
||||
var flag: String {
|
||||
switch self {
|
||||
case .hungarian: return "🇭🇺"
|
||||
case .english: return "🇬🇧"
|
||||
case .german: return "🇩🇪"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
class WatchL10n {
|
||||
static let shared = WatchL10n()
|
||||
|
||||
private let languageKey = "watch_language"
|
||||
private let syncWithiPhoneKey = "watch_sync_language_with_iphone"
|
||||
private static let appGroupID = "group.app.firka.firkaa"
|
||||
private var appGroupDefaults: UserDefaults? {
|
||||
UserDefaults(suiteName: Self.appGroupID)
|
||||
}
|
||||
|
||||
var currentLanguage: WatchLanguage {
|
||||
didSet {
|
||||
UserDefaults.standard.set(currentLanguage.rawValue, forKey: languageKey)
|
||||
appGroupDefaults?.set(currentLanguage.rawValue, forKey: languageKey)
|
||||
}
|
||||
}
|
||||
|
||||
var syncWithiPhone: Bool {
|
||||
didSet {
|
||||
UserDefaults.standard.set(syncWithiPhone, forKey: syncWithiPhoneKey)
|
||||
if syncWithiPhone {
|
||||
requestLanguageFromiPhone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var strings: [String: String] = [:]
|
||||
|
||||
private init() {
|
||||
let savedLanguage = UserDefaults.standard.string(forKey: languageKey) ?? "hu"
|
||||
self.currentLanguage = WatchLanguage(rawValue: savedLanguage) ?? .hungarian
|
||||
self.syncWithiPhone = UserDefaults.standard.bool(forKey: syncWithiPhoneKey)
|
||||
appGroupDefaults?.set(currentLanguage.rawValue, forKey: languageKey)
|
||||
loadStrings()
|
||||
}
|
||||
|
||||
private func loadStrings() {
|
||||
strings = Self.stringsForLanguage(currentLanguage)
|
||||
}
|
||||
|
||||
func setLanguage(_ language: WatchLanguage) {
|
||||
currentLanguage = language
|
||||
loadStrings()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
func updateFromiPhone(languageCode: String) {
|
||||
guard syncWithiPhone else { return }
|
||||
if let language = WatchLanguage(rawValue: languageCode) {
|
||||
setLanguage(language)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestLanguageFromiPhone() {
|
||||
WatchConnectivityManager.shared.requestLanguageFromPhone()
|
||||
}
|
||||
|
||||
func string(_ key: String) -> String {
|
||||
return strings[key] ?? key
|
||||
}
|
||||
|
||||
func string(_ key: String, _ args: CVarArg...) -> String {
|
||||
let format = strings[key] ?? key
|
||||
return String(format: format, arguments: args)
|
||||
}
|
||||
|
||||
static func stringsForLanguage(_ language: WatchLanguage) -> [String: String] {
|
||||
switch language {
|
||||
case .hungarian:
|
||||
return hungarianStrings
|
||||
case .english:
|
||||
return englishStrings
|
||||
case .german:
|
||||
return germanStrings
|
||||
}
|
||||
}
|
||||
|
||||
private static let hungarianStrings: [String: String] = [
|
||||
// Home View
|
||||
"current_lesson": "Jelenlegi óra",
|
||||
"next": "Következő",
|
||||
"break": "Szünet",
|
||||
"next_lesson": "Következő: %@",
|
||||
"first_lesson": "Első órád",
|
||||
"today_lessons_count": "Ma %d órád van",
|
||||
"no_more_lessons": "Ma nincs több órád",
|
||||
"pair_with_iphone": "Párosítsd az iPhone-oddal",
|
||||
"open_firka_on_iphone": "Nyisd meg a Firka appot az iPhone-odon",
|
||||
"updated": "Frissítve: %@",
|
||||
"minutes": "perc",
|
||||
"time_now": "most",
|
||||
"time_hours_minutes": "%d ó %d p",
|
||||
"time_hours": "%d óra",
|
||||
"time_minutes_only": "%d perc",
|
||||
|
||||
// Timetable View
|
||||
"free_day": "Szabad nap",
|
||||
"lesson_number": "%d. óra",
|
||||
"day_mon": "H",
|
||||
"day_tue": "K",
|
||||
"day_wed": "Sz",
|
||||
"day_thu": "Cs",
|
||||
"day_fri": "P",
|
||||
|
||||
// Grades View
|
||||
"grades_count": "%d jegy",
|
||||
"total_average": "Teljes átlag",
|
||||
"average": "Átlag:",
|
||||
"no_data": "Nincs adat",
|
||||
"no_grades": "Nincsenek jegyek",
|
||||
|
||||
// Lesson Detail
|
||||
"lesson_details": "Óra részletei",
|
||||
"cancelled": "Elmarad",
|
||||
"substitution": "Helyettesítés",
|
||||
"teacher": "Tanár",
|
||||
"room": "Terem",
|
||||
"topic": "Téma",
|
||||
|
||||
// Settings
|
||||
"settings": "Beállítások",
|
||||
"refresh_interval": "Frissítési időköz",
|
||||
"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",
|
||||
"updated": "Updated: %@",
|
||||
"minutes": "min",
|
||||
"time_now": "now",
|
||||
"time_hours_minutes": "%dh %dm",
|
||||
"time_hours": "%d hours",
|
||||
"time_minutes_only": "%d min",
|
||||
|
||||
// Timetable View
|
||||
"free_day": "Free Day",
|
||||
"lesson_number": "Lesson %d",
|
||||
"day_mon": "Mon",
|
||||
"day_tue": "Tue",
|
||||
"day_wed": "Wed",
|
||||
"day_thu": "Thu",
|
||||
"day_fri": "Fri",
|
||||
|
||||
// Grades View
|
||||
"grades_count": "%d grades",
|
||||
"total_average": "Total Average",
|
||||
"average": "Average:",
|
||||
"no_data": "No data",
|
||||
"no_grades": "No grades",
|
||||
|
||||
// Lesson Detail
|
||||
"lesson_details": "Lesson Details",
|
||||
"cancelled": "Cancelled",
|
||||
"substitution": "Substitution",
|
||||
"teacher": "Teacher",
|
||||
"room": "Room",
|
||||
"topic": "Topic",
|
||||
|
||||
// Settings
|
||||
"settings": "Settings",
|
||||
"refresh_interval": "Refresh Interval",
|
||||
"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",
|
||||
"updated": "Aktualisiert: %@",
|
||||
"minutes": "Min",
|
||||
"time_now": "jetzt",
|
||||
"time_hours_minutes": "%d Std %d Min",
|
||||
"time_hours": "%d Stunden",
|
||||
"time_minutes_only": "%d Min",
|
||||
|
||||
// Timetable View
|
||||
"free_day": "Freier Tag",
|
||||
"lesson_number": "%d. Stunde",
|
||||
"day_mon": "Mo",
|
||||
"day_tue": "Di",
|
||||
"day_wed": "Mi",
|
||||
"day_thu": "Do",
|
||||
"day_fri": "Fr",
|
||||
|
||||
// Grades View
|
||||
"grades_count": "%d Noten",
|
||||
"total_average": "Gesamtdurchschnitt",
|
||||
"average": "Durchschnitt:",
|
||||
"no_data": "Keine Daten",
|
||||
"no_grades": "Keine Noten",
|
||||
|
||||
// Lesson Detail
|
||||
"lesson_details": "Stundendetails",
|
||||
"cancelled": "Entfällt",
|
||||
"substitution": "Vertretung",
|
||||
"teacher": "Lehrer",
|
||||
"room": "Raum",
|
||||
"topic": "Thema",
|
||||
|
||||
// Settings
|
||||
"settings": "Einstellungen",
|
||||
"refresh_interval": "Aktualisierungsintervall",
|
||||
"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()
|
||||
}
|
||||
}
|
||||
452
firka/ios/FirkaWatch Watch App/Services/DataStore.swift
Normal file
@@ -0,0 +1,452 @@
|
||||
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 init() {
|
||||
checkTokenState()
|
||||
loadFromCache()
|
||||
}
|
||||
|
||||
|
||||
var hasValidToken: Bool {
|
||||
TokenManager.shared.loadToken() != nil
|
||||
}
|
||||
|
||||
func checkTokenState() {
|
||||
hasToken = TokenManager.shared.loadToken() != nil
|
||||
print("[Watch] Token state updated: hasToken = \(hasToken)")
|
||||
}
|
||||
|
||||
// MARK: - Cache Loading
|
||||
|
||||
func loadFromCache() {
|
||||
if let widgetData = WidgetData.load() {
|
||||
self.data = widgetData
|
||||
self.lastUpdated = widgetData.lastUpdated
|
||||
return
|
||||
}
|
||||
|
||||
guard let cachedData = loadWatchCache() else {
|
||||
return
|
||||
}
|
||||
|
||||
self.data = cachedData.widgetData
|
||||
self.lastUpdated = cachedData.lastUpdated
|
||||
}
|
||||
|
||||
private func loadWatchCache() -> CachedWatchData? {
|
||||
guard let containerURL = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: appGroupID
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fileURL = containerURL.appendingPathComponent(cacheFileName)
|
||||
|
||||
guard let cacheData = try? Data(contentsOf: fileURL) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
|
||||
return try? decoder.decode(CachedWatchData.self, from: cacheData)
|
||||
}
|
||||
|
||||
private func saveToCache(_ data: WidgetData) {
|
||||
guard let containerURL = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: appGroupID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
let fileURL = containerURL.appendingPathComponent(cacheFileName)
|
||||
let cached = CachedWatchData(widgetData: data, lastUpdated: Date())
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
|
||||
do {
|
||||
let encodedData = try encoder.encode(cached)
|
||||
try encodedData.write(to: fileURL)
|
||||
} catch {
|
||||
self.error = "Failed to save cache"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cache Management
|
||||
|
||||
func clearCache() {
|
||||
guard let containerURL = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: appGroupID
|
||||
) else { return }
|
||||
|
||||
let fileURL = containerURL.appendingPathComponent(cacheFileName)
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
|
||||
data = nil
|
||||
lastUpdated = nil
|
||||
|
||||
print("[Watch] Cache cleared")
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
clearCache()
|
||||
error = nil
|
||||
isLoading = false
|
||||
checkTokenState()
|
||||
|
||||
print("[Watch] All data cleared")
|
||||
}
|
||||
|
||||
func clearError() {
|
||||
error = nil
|
||||
print("[Watch] Error cleared")
|
||||
}
|
||||
|
||||
func setReauthRequired() {
|
||||
error = "token_expired"
|
||||
print("[Watch] Reauth required state set")
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
func refreshAllWithRecovery() async {
|
||||
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 nil
|
||||
}
|
||||
|
||||
// Minutes
|
||||
let minutes = Int(elapsed / 60)
|
||||
if minutes < 60 {
|
||||
return minutes == 1 ? "1 perce" : "\(minutes) perce"
|
||||
}
|
||||
|
||||
// Hours
|
||||
let hours = Int(elapsed / 3600)
|
||||
if hours < 24 {
|
||||
return hours == 1 ? "1 órája" : "\(hours) órája"
|
||||
}
|
||||
|
||||
// Days
|
||||
let days = Int(elapsed / 86400)
|
||||
return days == 1 ? "1 napja" : "\(days) napja"
|
||||
}
|
||||
|
||||
/// Returns true if data is stale (> 1 hour old or never updated)
|
||||
var isStale: Bool {
|
||||
guard let lastUpdated = lastUpdated else { return true }
|
||||
|
||||
let elapsed = Date().timeIntervalSince(lastUpdated)
|
||||
return elapsed > 3600 // 1 hour
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
|
||||
class WatchConnectivityManager: NSObject, WCSessionDelegate {
|
||||
static let shared = WatchConnectivityManager()
|
||||
private let lastAppliedTokenUpdateKey = "watch_last_applied_token_update_ms"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func activate() {
|
||||
print("[Watch] WatchConnectivityManager.activate() called")
|
||||
if WCSession.isSupported() {
|
||||
print("[Watch] WCSession is supported, activating...")
|
||||
WCSession.default.delegate = self
|
||||
WCSession.default.activate()
|
||||
} else {
|
||||
print("[Watch] WCSession is NOT supported!")
|
||||
}
|
||||
}
|
||||
|
||||
func session(
|
||||
_ session: WCSession,
|
||||
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||
error: Error?
|
||||
) {
|
||||
print("[Watch] Session activation completed with state: \(activationState.rawValue)")
|
||||
if let error = error {
|
||||
print("[Watch] Activation error: \(error.localizedDescription)")
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
if activationState == .activated {
|
||||
let context = session.receivedApplicationContext
|
||||
if !context.isEmpty {
|
||||
self.processApplicationContext(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func session(
|
||||
_ session: WCSession,
|
||||
didReceiveApplicationContext applicationContext: [String: Any]
|
||||
) {
|
||||
print("[Watch] didReceiveApplicationContext called")
|
||||
DispatchQueue.main.async {
|
||||
self.processApplicationContext(applicationContext)
|
||||
}
|
||||
}
|
||||
|
||||
func session(
|
||||
_ session: WCSession,
|
||||
didReceiveUserInfo userInfo: [String: Any] = [:]
|
||||
) {
|
||||
print("[Watch] didReceiveUserInfo called")
|
||||
DispatchQueue.main.async {
|
||||
self.processUserInfo(userInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func session(
|
||||
_ session: WCSession,
|
||||
didReceiveMessage message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void
|
||||
) {
|
||||
print("[Watch] didReceiveMessage called: \(message)")
|
||||
|
||||
guard let action = message["action"] as? String else {
|
||||
replyHandler(["error": "no_action"])
|
||||
return
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "getToken":
|
||||
handleGetTokenRequest(replyHandler: replyHandler)
|
||||
default:
|
||||
replyHandler(["error": "unknown_action"])
|
||||
}
|
||||
}
|
||||
|
||||
private func handleGetTokenRequest(replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
guard 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
|
||||
}
|
||||
|
||||
print("[Watch] Requesting token from iPhone...")
|
||||
|
||||
WCSession.default.sendMessage(
|
||||
["action": "requestToken"],
|
||||
replyHandler: { response in
|
||||
print("[Watch] Received response from iPhone")
|
||||
DispatchQueue.main.async {
|
||||
if let authDict = response["auth"] as? [String: Any] {
|
||||
print("[Watch] Token received from iPhone")
|
||||
self.processAuthData(authDict)
|
||||
} else if let error = response["error"] as? String {
|
||||
print("[Watch] Token request error: \(error)")
|
||||
}
|
||||
}
|
||||
},
|
||||
errorHandler: { error in
|
||||
print("[Watch] Token request failed: \(error.localizedDescription)")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func processApplicationContext(_ context: [String: Any]) {
|
||||
if let authDict = context["auth"] as? [String: Any] {
|
||||
print("[Watch] Received auth from iPhone")
|
||||
processAuthData(authDict)
|
||||
}
|
||||
|
||||
if let language = context["language"] as? String {
|
||||
print("[Watch] Received language from iPhone: \(language)")
|
||||
WatchL10n.shared.updateFromiPhone(languageCode: language)
|
||||
}
|
||||
}
|
||||
|
||||
private func processUserInfo(_ userInfo: [String: Any]) {
|
||||
if let messageId = userInfo["id"] as? String {
|
||||
switch messageId {
|
||||
case "token_update":
|
||||
if let authDict = userInfo["auth"] as? [String: Any] {
|
||||
print("[Watch] Received token_update via userInfo")
|
||||
processAuthData(authDict)
|
||||
}
|
||||
case "language_update":
|
||||
if let language = userInfo["language"] as? String {
|
||||
print("[Watch] Received language_update via userInfo: \(language)")
|
||||
WatchL10n.shared.updateFromiPhone(languageCode: language)
|
||||
}
|
||||
case "reauth_required":
|
||||
print("[Watch] Received reauth_required notification from iPhone")
|
||||
DataStore.shared.setReauthRequired()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendTokenToiPhoneInBackground() {
|
||||
guard WCSession.default.activationState == .activated else {
|
||||
print("[Watch] Cannot send token: session not activated")
|
||||
return
|
||||
}
|
||||
|
||||
guard let token = TokenManager.shared.loadToken() else {
|
||||
print("[Watch] No token to send to iPhone")
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
print("[Watch] Language received from iPhone: \(language)")
|
||||
WatchL10n.shared.updateFromiPhone(languageCode: language)
|
||||
}
|
||||
}
|
||||
},
|
||||
errorHandler: { error in
|
||||
print("[Watch] Language request failed: \(error.localizedDescription)")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func processAuthData(_ authDict: [String: Any]) {
|
||||
print("[Watch] processAuthData called")
|
||||
do {
|
||||
let 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 shouldForceAccountSwitch: Bool
|
||||
if incomingSentAtMs > 0,
|
||||
let currentToken,
|
||||
!token.isSameAccount(as: currentToken) {
|
||||
shouldForceAccountSwitch = true
|
||||
} else {
|
||||
shouldForceAccountSwitch = false
|
||||
}
|
||||
|
||||
if incomingSentAtMs <= 0,
|
||||
let currentToken,
|
||||
!token.isNewer(than: currentToken) {
|
||||
print("[Watch] Ignoring stale token_update without sentAtMs")
|
||||
return
|
||||
}
|
||||
|
||||
print("[Watch] Token decoded, saving... (sentAtMs: \(incomingSentAtMs), forceSwitch: \(shouldForceAccountSwitch))")
|
||||
|
||||
try TokenManager.shared.saveToken(
|
||||
token,
|
||||
syncToICloud: false,
|
||||
forceAccountSwitch: shouldForceAccountSwitch
|
||||
)
|
||||
print("[Watch] Token saved successfully")
|
||||
if incomingSentAtMs > 0 {
|
||||
lastAppliedTokenUpdateMs = max(previousSentAtMs, incomingSentAtMs)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
497
firka/ios/FirkaWatch Watch App/Views/HomeView.swift
Normal file
@@ -0,0 +1,497 @@
|
||||
import SwiftUI
|
||||
internal import Combine
|
||||
|
||||
struct HomeView: View {
|
||||
let dataStore: DataStore
|
||||
@State private var currentTime = Date()
|
||||
|
||||
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
if let breakInfo = dataStore.data?.timetable.currentBreak {
|
||||
breakView(breakInfo)
|
||||
} else if !dataStore.hasToken && dataStore.data == nil {
|
||||
noTokenView
|
||||
} else if let current = currentLesson {
|
||||
currentLessonView(current)
|
||||
} else if let next = nextLesson {
|
||||
if isBreakBetweenLessons {
|
||||
breakBetweenView(next)
|
||||
} else {
|
||||
beforeSchoolView(next)
|
||||
}
|
||||
} else {
|
||||
noMoreLessonsView
|
||||
}
|
||||
|
||||
refreshButton
|
||||
|
||||
if dataStore.lastUpdated != nil {
|
||||
lastUpdatedView
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onReceive(timer) { _ in
|
||||
currentTime = Date()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Refresh Button
|
||||
|
||||
@State private var refreshStatus: RefreshStatus = .idle
|
||||
@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 == .idle {
|
||||
wasLoadingFromBackground = true
|
||||
}
|
||||
if !newValue && wasLoadingFromBackground && refreshStatus == .idle {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(lesson.displayName)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(2)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
if let room = lesson.roomName {
|
||||
Label(room, systemImage: "door.right.hand.closed")
|
||||
}
|
||||
Text(lesson.timeString)
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
// Next lesson preview
|
||||
if let next = nextLesson {
|
||||
Text("next".localized)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
FirkaCard {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(next.displayName)
|
||||
.font(.subheadline)
|
||||
if let room = next.roomName {
|
||||
Text(room)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text(next.start, style: .time)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Break Between Lessons (with CountdownRing)
|
||||
|
||||
@ViewBuilder
|
||||
private func breakBetweenView(_ next: WidgetLesson) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Text("break".localized)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
let remaining = max(0, Int(next.start.timeIntervalSince(now) / 60))
|
||||
|
||||
HStack(spacing: 10) {
|
||||
CountdownRing(
|
||||
totalMinutes: 15,
|
||||
remainingMinutes: remaining,
|
||||
label: "minutes".localized,
|
||||
size: 56,
|
||||
lineWidth: 6,
|
||||
displayOffset: 1
|
||||
)
|
||||
.id("break-\(next.start.timeIntervalSince1970)")
|
||||
|
||||
FirkaCard {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("next_lesson".localized(next.displayName))
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(2)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
if let room = next.roomName {
|
||||
Label(room, systemImage: "door.right.hand.closed")
|
||||
}
|
||||
Text(next.start, style: .time)
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Before School View
|
||||
|
||||
@ViewBuilder
|
||||
private func beforeSchoolView(_ first: WidgetLesson) -> some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("first_lesson".localized)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
FirkaCard {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(first.displayName)
|
||||
.font(.headline)
|
||||
|
||||
HStack {
|
||||
if let room = first.roomName {
|
||||
Label(room, systemImage: "door.right.hand.closed")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(relativeTimeString(to: first.start))
|
||||
.font(.caption)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !todayLessons.isEmpty {
|
||||
Text("today_lessons_count".localized(todayLessons.count))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - No More Lessons View
|
||||
|
||||
private var noMoreLessonsView: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 44))
|
||||
.foregroundColor(.green)
|
||||
|
||||
Text("no_more_lessons".localized)
|
||||
.font(.headline)
|
||||
|
||||
if let (nextLesson, dayLabel) = nextSchoolDayFirstLesson {
|
||||
Text(dayLabel)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 8)
|
||||
|
||||
FirkaCard {
|
||||
HStack {
|
||||
Text(nextLesson.displayName)
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Text(nextLesson.start, style: .time)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var nextSchoolDayFirstLesson: (lesson: WidgetLesson, label: String)? {
|
||||
guard let allLessons = dataStore.data?.timetable.allLessons, !allLessons.isEmpty else {
|
||||
if let tomorrow = dataStore.data?.timetable.tomorrow.first {
|
||||
return (tomorrow, "tomorrow_first_lesson".localized)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
let calendar = Calendar.current
|
||||
let now = currentTime
|
||||
let todayStr = formatDateForHomeView(now)
|
||||
|
||||
let futureLessons = allLessons.filter { $0.date > todayStr }
|
||||
.sorted { $0.date < $1.date || ($0.date == $1.date && $0.start < $1.start) }
|
||||
|
||||
guard let firstFuture = futureLessons.first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let label = labelForDate(firstFuture.date, relativeTo: now)
|
||||
|
||||
return (firstFuture, label)
|
||||
}
|
||||
|
||||
private func formatDateForHomeView(_ date: Date) -> String {
|
||||
let calendar = Calendar.current
|
||||
let components = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
return String(format: "%04d-%02d-%02d",
|
||||
components.year ?? 0,
|
||||
components.month ?? 0,
|
||||
components.day ?? 0)
|
||||
}
|
||||
|
||||
private func labelForDate(_ dateStr: String, relativeTo: Date) -> String {
|
||||
let calendar = Calendar.current
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone.current
|
||||
|
||||
guard let targetDate = formatter.date(from: dateStr) else {
|
||||
return "next_school_day".localized
|
||||
}
|
||||
|
||||
let today = calendar.startOfDay(for: relativeTo)
|
||||
let target = calendar.startOfDay(for: targetDate)
|
||||
|
||||
let daysDiff = calendar.dateComponents([.day], from: today, to: target).day ?? 0
|
||||
|
||||
switch daysDiff {
|
||||
case 1:
|
||||
return "tomorrow_first_lesson".localized
|
||||
case 2...6:
|
||||
let dayFormatter = DateFormatter()
|
||||
let langCode = WatchL10n.shared.currentLanguage.rawValue
|
||||
dayFormatter.locale = Locale(identifier: langCode)
|
||||
dayFormatter.dateFormat = "EEEE"
|
||||
let dayName = dayFormatter.string(from: targetDate).capitalized
|
||||
return "day_first_lesson".localized(dayName)
|
||||
default:
|
||||
return "next_school_day".localized
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Break/Vacation View
|
||||
|
||||
@ViewBuilder
|
||||
private func breakView(_ breakInfo: BreakInfo) -> some View {
|
||||
VStack(spacing: 12) {
|
||||
let icon = SeasonalIconHelper.iconName(for: breakInfo.nameKey, season: nil)
|
||||
let color = SeasonalIconHelper.iconColor(for: breakInfo.nameKey, season: nil)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 44))
|
||||
.foregroundColor(color)
|
||||
|
||||
Text(breakInfo.name)
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - No Token View
|
||||
|
||||
private var noTokenView: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "iphone.and.arrow.right.inward")
|
||||
.font(.system(size: 44))
|
||||
.foregroundColor(.blue)
|
||||
|
||||
Text("pair_with_iphone".localized)
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("open_firka_on_iphone".localized)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Last Updated View
|
||||
|
||||
private var lastUpdatedView: some View {
|
||||
HStack(spacing: 4) {
|
||||
if dataStore.isStale {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.yellow)
|
||||
}
|
||||
if let text = dataStore.timeSinceUpdate {
|
||||
Text("updated".localized(text))
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// MARK: - Relative Time Helper
|
||||
|
||||
private func relativeTimeString(to date: Date) -> String {
|
||||
let now = currentTime
|
||||
let interval = date.timeIntervalSince(now)
|
||||
|
||||
guard interval > 0 else {
|
||||
return "time_now".localized
|
||||
}
|
||||
|
||||
let totalMinutes = Int(interval / 60)
|
||||
let hours = totalMinutes / 60
|
||||
let minutes = totalMinutes % 60
|
||||
|
||||
if hours > 0 && minutes > 0 {
|
||||
return "time_hours_minutes".localized(hours, minutes)
|
||||
} else if hours > 0 {
|
||||
return "time_hours".localized(hours)
|
||||
} else {
|
||||
return "time_minutes_only".localized(minutes)
|
||||
}
|
||||
}
|
||||
}
|
||||
109
firka/ios/FirkaWatch Watch App/Views/LessonDetailView.swift
Normal file
@@ -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,
|
||||
syncToICloud: 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()
|
||||
}
|
||||
74
firka/ios/FirkaWatch Watch App/Views/SettingsView.swift
Normal file
@@ -0,0 +1,74 @@
|
||||
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()
|
||||
DataStore.shared.clearAll()
|
||||
}
|
||||
}
|
||||
357
firka/ios/FirkaWatch Watch App/Views/TimetableView.swift
Normal file
@@ -0,0 +1,357 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TimetableView: View {
|
||||
let dataStore: DataStore
|
||||
|
||||
@State private var selectedDay: Int = 0
|
||||
@State private var weekOffset: Int = 0
|
||||
|
||||
private var dayLabels: [String] {
|
||||
[
|
||||
"day_mon".localized,
|
||||
"day_tue".localized,
|
||||
"day_wed".localized,
|
||||
"day_thu".localized,
|
||||
"day_fri".localized
|
||||
]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
daySelector
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
lessonsContent
|
||||
}
|
||||
.onAppear {
|
||||
updateWeekAndDay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateWeekAndDay() {
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
|
||||
if shouldShowNextWeek() {
|
||||
weekOffset = 1
|
||||
selectedDay = findFirstSchoolDay(weekOffset: 1)
|
||||
return
|
||||
}
|
||||
|
||||
weekOffset = 0
|
||||
let weekday = calendar.component(.weekday, from: now)
|
||||
let todayIndex = weekday - 2
|
||||
|
||||
if todayIndex < 0 || todayIndex > 4 {
|
||||
selectedDay = findFirstSchoolDay(weekOffset: 0)
|
||||
return
|
||||
}
|
||||
|
||||
if areTodayLessonsDone(dayIndex: todayIndex) {
|
||||
if let nextDay = findNextSchoolDay(after: todayIndex) {
|
||||
selectedDay = nextDay
|
||||
} else {
|
||||
selectedDay = todayIndex
|
||||
}
|
||||
} else {
|
||||
selectedDay = todayIndex
|
||||
}
|
||||
}
|
||||
|
||||
private func areTodayLessonsDone(dayIndex: Int) -> Bool {
|
||||
let todayLessons = lessonsForDay(dayIndex)
|
||||
guard !todayLessons.isEmpty else { return true }
|
||||
|
||||
let now = Date()
|
||||
let lastLesson = todayLessons.sorted { $0.end > $1.end }.first
|
||||
return lastLesson.map { now > $0.end } ?? true
|
||||
}
|
||||
|
||||
private func findNextSchoolDay(after dayIndex: Int) -> Int? {
|
||||
for day in (dayIndex + 1)...4 {
|
||||
if !lessonsForDay(day).isEmpty {
|
||||
return day
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func findFirstSchoolDay(weekOffset: Int) -> Int {
|
||||
let oldOffset = self.weekOffset
|
||||
for day in 0...4 {
|
||||
let lessons = lessonsForDayWithOffset(day, weekOffset: weekOffset)
|
||||
if !lessons.isEmpty {
|
||||
return day
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private func lessonsForDayWithOffset(_ day: Int, weekOffset: Int) -> [WidgetLesson] {
|
||||
guard let data = dataStore.data else { return [] }
|
||||
|
||||
let allLessons: [WidgetLesson]
|
||||
if let all = data.timetable.allLessons, !all.isEmpty {
|
||||
allLessons = all
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
|
||||
let targetDateStr = getDateStringForDayWithOffset(day, weekOffset: weekOffset)
|
||||
return allLessons.filter { $0.date == targetDateStr }
|
||||
}
|
||||
|
||||
private func getDateStringForDayWithOffset(_ day: Int, weekOffset: Int) -> String {
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
|
||||
let weekday = calendar.component(.weekday, from: now)
|
||||
let daysFromMonday = (weekday == 1) ? -6 : (2 - weekday)
|
||||
|
||||
guard let monday = calendar.date(byAdding: .day, value: daysFromMonday, to: now) else {
|
||||
return ""
|
||||
}
|
||||
|
||||
let totalDaysToAdd = day + (weekOffset * 7)
|
||||
guard let targetDate = calendar.date(byAdding: .day, value: totalDaysToAdd, to: monday) else {
|
||||
return ""
|
||||
}
|
||||
|
||||
return formatDate(targetDate)
|
||||
}
|
||||
|
||||
private func shouldShowNextWeek() -> Bool {
|
||||
guard let allLessons = dataStore.data?.timetable.allLessons, !allLessons.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
let calendar = Calendar.current
|
||||
|
||||
let weekday = calendar.component(.weekday, from: now)
|
||||
let daysFromMonday = (weekday == 1) ? -6 : (2 - weekday)
|
||||
guard let monday = calendar.date(byAdding: .day, value: daysFromMonday, to: now),
|
||||
let friday = calendar.date(byAdding: .day, value: 4, to: monday) else {
|
||||
return false
|
||||
}
|
||||
let fridayString = formatDate(friday)
|
||||
let mondayString = formatDate(monday)
|
||||
|
||||
let currentWeekLessons = allLessons.filter { lesson in
|
||||
lesson.date >= mondayString && lesson.date <= fridayString
|
||||
}
|
||||
|
||||
guard !currentWeekLessons.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
let lastLesson = currentWeekLessons
|
||||
.sorted { $0.date > $1.date || ($0.date == $1.date && $0.end > $1.end) }
|
||||
.first
|
||||
|
||||
guard let last = lastLesson else {
|
||||
return false
|
||||
}
|
||||
|
||||
return now > last.end
|
||||
}
|
||||
|
||||
// MARK: - Day Selector
|
||||
|
||||
private var daySelector: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(0..<5, id: \.self) { day in
|
||||
Button(action: { selectedDay = day }) {
|
||||
Text(dayLabels[day])
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 32)
|
||||
.foregroundColor(selectedDay == day ? .white : .primary)
|
||||
.background(selectedDay == day ? Color.blue : Color.clear)
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(isToday(day) && selectedDay != day ? Color.blue : Color.clear, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private func isToday(_ day: Int) -> Bool {
|
||||
guard weekOffset == 0 else { return false }
|
||||
let weekday = Calendar.current.component(.weekday, from: Date())
|
||||
return day == weekday - 2
|
||||
}
|
||||
|
||||
// MARK: - Lessons Content
|
||||
|
||||
@ViewBuilder
|
||||
private var lessonsContent: some View {
|
||||
let lessons = lessonsForDay(selectedDay)
|
||||
|
||||
if lessons.isEmpty {
|
||||
freeDayView
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 6) {
|
||||
ForEach(lessons) { lesson in
|
||||
NavigationLink {
|
||||
LessonDetailView(lesson: lesson)
|
||||
} label: {
|
||||
lessonRow(lesson)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func lessonsForDay(_ day: Int) -> [WidgetLesson] {
|
||||
guard let data = dataStore.data else { return [] }
|
||||
|
||||
let allLessons: [WidgetLesson]
|
||||
if let all = data.timetable.allLessons, !all.isEmpty {
|
||||
allLessons = all
|
||||
} else {
|
||||
var combined: [WidgetLesson] = []
|
||||
combined.append(contentsOf: data.timetable.today)
|
||||
combined.append(contentsOf: data.timetable.tomorrow)
|
||||
if let nextSchoolDay = data.timetable.nextSchoolDay {
|
||||
combined.append(contentsOf: nextSchoolDay)
|
||||
}
|
||||
allLessons = combined
|
||||
}
|
||||
|
||||
let targetDateStr = getDateStringForDay(day)
|
||||
|
||||
let uniqueDates = Set(allLessons.map { $0.date }).sorted()
|
||||
print("[Watch] lessonsForDay: day=\(day), weekOffset=\(weekOffset), targetDate=\(targetDateStr), lessons=\(allLessons.count)")
|
||||
print("[Watch] Unique dates in lessons: \(uniqueDates)")
|
||||
|
||||
if let first = allLessons.first {
|
||||
let cal = Calendar.current
|
||||
let comp = cal.dateComponents([.year, .month, .day, .hour, .minute], from: first.start)
|
||||
print("[Watch] First lesson: date=\(first.date), start=\(comp.year!)-\(comp.month!)-\(comp.day!) \(comp.hour!):\(comp.minute!)")
|
||||
}
|
||||
|
||||
let filtered = allLessons.filter { $0.date == targetDateStr }
|
||||
print("[Watch] Filtered lessons: \(filtered.count) for \(targetDateStr)")
|
||||
|
||||
return filtered.sorted { ($0.lessonNumber ?? 0) < ($1.lessonNumber ?? 0) }
|
||||
}
|
||||
|
||||
private func getDateStringForDay(_ day: Int) -> String {
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
|
||||
let weekday = calendar.component(.weekday, from: now)
|
||||
let daysFromMonday = (weekday == 1) ? -6 : (2 - weekday)
|
||||
|
||||
guard let monday = calendar.date(byAdding: .day, value: daysFromMonday, to: now) else {
|
||||
return ""
|
||||
}
|
||||
|
||||
let totalDaysToAdd = day + (weekOffset * 7)
|
||||
guard let targetDate = calendar.date(byAdding: .day, value: totalDaysToAdd, to: monday) else {
|
||||
return ""
|
||||
}
|
||||
|
||||
return formatDate(targetDate)
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let calendar = Calendar.current
|
||||
let components = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
return String(format: "%04d-%02d-%02d",
|
||||
components.year ?? 0,
|
||||
components.month ?? 0,
|
||||
components.day ?? 0)
|
||||
}
|
||||
|
||||
private var freeDayView: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "sun.max.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.yellow)
|
||||
|
||||
Text("free_day".localized)
|
||||
.font(.headline)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Lesson Row
|
||||
|
||||
@ViewBuilder
|
||||
private func lessonRow(_ lesson: WidgetLesson) -> some View {
|
||||
FirkaCard(isHighlighted: lesson.isCurrentlyActive) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
if let number = lesson.lessonNumber {
|
||||
Text("\(number).")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.blue)
|
||||
.frame(width: 24, alignment: .leading)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(lesson.displayName)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(1)
|
||||
.strikethrough(lesson.isCancelled)
|
||||
.opacity(lesson.isCancelled ? 0.5 : 1)
|
||||
|
||||
if lesson.isSubstitution {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(lesson.start, style: .time)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 4) {
|
||||
if let teacher = lesson.teacher {
|
||||
Text(teacher)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let room = lesson.roomName {
|
||||
Text("•")
|
||||
Text(room)
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.opacity(lesson.isCancelled ? 0.5 : 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.opacity(lesson.isCancelled ? 0.6 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct TimetableView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
TimetableView(dataStore: DataStore.shared)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,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))
|
||||
}
|
||||
}
|
||||
378
firka/ios/HomeWidgetsExtension/Providers/TimetableProvider.swift
Normal file
@@ -0,0 +1,378 @@
|
||||
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 static let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone.current
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private func parseNextSchoolDayDate(_ dateString: String?) -> Date? {
|
||||
guard let dateString = dateString else { return nil }
|
||||
return Self.dateFormatter.date(from: dateString)
|
||||
}
|
||||
|
||||
func placeholder(in context: Context) -> TimetableEntry {
|
||||
TimetableEntry(
|
||||
date: Date(),
|
||||
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 nextSchoolDayDateString = data?.timetable.nextSchoolDayDate,
|
||||
let nextSchoolDayDate = parseNextSchoolDayDate(nextSchoolDayDateString) {
|
||||
let nextSchoolDay = calendar.startOfDay(for: nextSchoolDayDate)
|
||||
let dayBeforeNextSchoolDay = calendar.date(byAdding: .day, value: -1, to: nextSchoolDay)!
|
||||
|
||||
if dayBeforeNextSchoolDay > now {
|
||||
entries.append(createEntry(for: configuration, date: dayBeforeNextSchoolDay))
|
||||
}
|
||||
|
||||
if nextSchoolDay > now {
|
||||
entries.append(createEntry(for: configuration, date: nextSchoolDay))
|
||||
}
|
||||
}
|
||||
|
||||
let uniqueDates = Set(entries.map { $0.date })
|
||||
entries = uniqueDates.map { date in
|
||||
entries.first { $0.date == date }!
|
||||
}
|
||||
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)
|
||||
|
||||
var lessons = data.timetable.today
|
||||
var isNextDay = false
|
||||
|
||||
if let firstTodayLesson = lessons.first {
|
||||
let todayLessonDay = calendar.startOfDay(for: firstTodayLesson.start)
|
||||
|
||||
if entryDay > todayLessonDay {
|
||||
lessons = data.timetable.tomorrow
|
||||
if let firstTomorrowLesson = lessons.first {
|
||||
let tomorrowLessonDay = calendar.startOfDay(for: firstTomorrowLesson.start)
|
||||
isNextDay = entryDay < tomorrowLessonDay
|
||||
}
|
||||
} else {
|
||||
let lastLesson = lessons.last
|
||||
if let last = lastLesson, date > last.end {
|
||||
lessons = data.timetable.tomorrow
|
||||
isNextDay = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lessons = data.timetable.tomorrow
|
||||
if !lessons.isEmpty {
|
||||
isNextDay = true
|
||||
}
|
||||
}
|
||||
|
||||
if lessons.isEmpty {
|
||||
if let nextSchoolDayLessons = data.timetable.nextSchoolDay, !nextSchoolDayLessons.isEmpty {
|
||||
if let nextSchoolDayDate = parseNextSchoolDayDate(data.timetable.nextSchoolDayDate) {
|
||||
let nextSchoolDay = calendar.startOfDay(for: nextSchoolDayDate)
|
||||
let dayBeforeNextSchoolDay = calendar.date(byAdding: .day, value: -1, to: nextSchoolDay)!
|
||||
|
||||
if entryDay == nextSchoolDay {
|
||||
let currentLesson = nextSchoolDayLessons.first { lesson in
|
||||
return date >= lesson.start && date <= lesson.end
|
||||
}
|
||||
let nextLesson = nextSchoolDayLessons.first { $0.start > date }
|
||||
|
||||
return TimetableEntry(
|
||||
date: date,
|
||||
configuration: configuration,
|
||||
data: data,
|
||||
lessons: nextSchoolDayLessons,
|
||||
currentLesson: currentLesson,
|
||||
nextLesson: nextLesson,
|
||||
isNextDay: false,
|
||||
isNextSchoolDay: false,
|
||||
nextSchoolDayDateString: nil,
|
||||
breakInfo: nil,
|
||||
state: .normal,
|
||||
debugInfo: WidgetData.lastError
|
||||
)
|
||||
}
|
||||
|
||||
if entryDay == dayBeforeNextSchoolDay {
|
||||
return TimetableEntry(
|
||||
date: date,
|
||||
configuration: configuration,
|
||||
data: data,
|
||||
lessons: nextSchoolDayLessons,
|
||||
currentLesson: nil,
|
||||
nextLesson: nextSchoolDayLessons.first,
|
||||
isNextDay: true,
|
||||
isNextSchoolDay: false,
|
||||
nextSchoolDayDateString: nil,
|
||||
breakInfo: nil,
|
||||
state: .normal,
|
||||
debugInfo: WidgetData.lastError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return TimetableEntry(
|
||||
date: date,
|
||||
configuration: configuration,
|
||||
data: data,
|
||||
lessons: nextSchoolDayLessons,
|
||||
currentLesson: nil,
|
||||
nextLesson: nextSchoolDayLessons.first,
|
||||
isNextDay: false,
|
||||
isNextSchoolDay: true,
|
||||
nextSchoolDayDateString: data.timetable.nextSchoolDayDate,
|
||||
breakInfo: nil,
|
||||
state: .normal,
|
||||
debugInfo: WidgetData.lastError
|
||||
)
|
||||
}
|
||||
|
||||
return TimetableEntry(
|
||||
date: date,
|
||||
configuration: configuration,
|
||||
data: data,
|
||||
lessons: [],
|
||||
currentLesson: nil,
|
||||
nextLesson: nil,
|
||||
isNextDay: isNextDay,
|
||||
isNextSchoolDay: false,
|
||||
nextSchoolDayDateString: nil,
|
||||
breakInfo: nil,
|
||||
state: isNextDay ? .noMoreLessons : .unavailable,
|
||||
debugInfo: WidgetData.lastError
|
||||
)
|
||||
}
|
||||
|
||||
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: false,
|
||||
nextSchoolDayDateString: nil,
|
||||
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))")
|
||||
}
|
||||
}
|
||||