Compare commits
219 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd3884de16 | ||
|
|
c646ea2d51 | ||
|
|
a22459794a | ||
|
|
91a526703e | ||
|
|
38ff8af578 | ||
|
|
58c16e9aa8 | ||
|
|
748bff63ea | ||
|
|
812c1a008e | ||
|
|
b71aa12751 | ||
|
|
c16cbdb186 | ||
|
|
8f28fa328c | ||
|
|
8af53422dc | ||
|
|
dda4bfd9d3 | ||
|
|
d92e420b34 | ||
|
|
b54fa36671 | ||
|
|
60375e93d1 | ||
|
|
eb1e4b4cfd | ||
|
|
f4eb4e7487 | ||
|
|
584f340778 | ||
|
|
b9de46f0ed | ||
|
|
0f3dcf58a5 | ||
|
|
b58e60a1f8 | ||
|
|
42b8eea0ba | ||
|
|
ce9781f1c0 | ||
|
|
e5224cbfff | ||
|
|
2d14c41070 | ||
|
|
ecb1745d9e | ||
|
|
0abc568a64 | ||
|
|
0845290929 | ||
|
|
3a0eb5fe54 | ||
|
|
503a51ca23 | ||
|
|
0781685015 | ||
|
|
2a4836c42f | ||
|
|
4ff6f2fdb0 | ||
|
|
f80ce9bc4f | ||
|
|
91bf7a359c | ||
|
|
6d7d3641ea | ||
|
|
873e0f209b | ||
|
|
8d768ca6b8 | ||
|
|
229eabfd4f | ||
|
|
80599c13d8 | ||
|
|
c92e83aadd | ||
|
|
47670fb558 | ||
|
|
b8058cd4cb | ||
|
|
4fd3e2a09b | ||
|
|
0e0fa549cf | ||
| 39e9c097a0 | |||
|
|
ea8315a993 | ||
|
|
6d33f6b0d8 | ||
|
|
8c4bbd0905 | ||
|
|
fe70fc7bd1 | ||
|
|
eb3ed957f1 | ||
| cd525898bb | |||
| 0e08919209 | |||
| e651552dec | |||
| 8f683561b9 | |||
| 519c9a4043 | |||
|
|
626d6aefdd | ||
|
|
887d765f65 | ||
|
|
34cf77f216 | ||
| 7b382563ba | |||
| 667a8e0e4d | |||
| 147dff3696 | |||
| 0fd36de4a3 | |||
| d4fe91860a | |||
| 45d0b298c4 | |||
| c812b0721f | |||
| b62140c0d0 | |||
| f55cc65f07 | |||
| 3690cf0462 | |||
| 233f0c9ed0 | |||
| 6912e44b7e | |||
| 369febba91 | |||
| 2ca5e8c54b | |||
| dd515955ba | |||
| 97d1f005b4 | |||
| 92e94f60c5 | |||
| ba53f30cce | |||
| a34c8e23d3 | |||
| ab1208999d | |||
| 2b51523eb1 | |||
| 9f3190495b | |||
| 14fdf00259 | |||
| 815604d144 | |||
| 8f499aac21 | |||
| f24f340d63 | |||
| ccf7443f26 | |||
| 2f0c6ca9df | |||
| ff73bafb0d | |||
| 978df96aaf | |||
| 07b8714c7e | |||
| 8881f8c674 | |||
| 569147ae8a | |||
| 947e1d12cf | |||
| fd750ae6ad | |||
| f31b719605 | |||
| 0bcdf35060 | |||
| a0c1f3bb1f | |||
| 6e35ca9d72 | |||
| 3b6e5d8213 | |||
| 894d897778 | |||
| a96be41d01 | |||
| 24876aebd6 | |||
| e4aea80f0b | |||
| 56cbaa6d16 | |||
| 850800864d | |||
| a92ea1dcf6 | |||
| a9829b2163 | |||
| 4f1c903384 | |||
| b045980bb4 | |||
| 352f9f223b | |||
| 3d41113c0f | |||
| e616893ad2 | |||
| 8d934dd0f8 | |||
| 3e31804fe5 | |||
| 5f74a99f5a | |||
| 6e16b9a37c | |||
| ebcf49d957 | |||
| aec2453a05 | |||
| 6c64ac4a35 | |||
| ae06a61b05 | |||
| d5c81d443e | |||
| 8b1b5cc4cf | |||
| 3d09187d6f | |||
| 722068fdde | |||
| c471768598 | |||
| 2009418887 | |||
| 94d3802e95 | |||
| 4fe8af2e66 | |||
| 57122a4c3f | |||
| e5fac2609f | |||
| dd4ccf2736 | |||
|
|
1356fd3eb3 | ||
|
|
fde6a47adc | ||
|
|
c1edbe0971 | ||
|
|
f04517749b | ||
|
|
c36d656178 | ||
| 8c4735ccb3 | |||
| a665478930 | |||
| 033ab39c59 | |||
| 6bf6b46119 | |||
| 2e425dd757 | |||
| 942ecc9db2 | |||
| b4d87978e2 | |||
| e84ea8c383 | |||
| 5354c601eb | |||
| 9533fdaeec | |||
| c79934f946 | |||
| d21b8955a7 | |||
| c95128a48e | |||
| 308ff7f14c | |||
| 69eae36ef8 | |||
| 788f808d4e | |||
| c16c899e66 | |||
| 6e2eeea8f0 | |||
| 191ee62be9 | |||
| b51169ab3c | |||
| 1902ace989 | |||
| d3e30689f0 | |||
| c1aae7adb1 | |||
| ee72056074 | |||
| 4bd585d1dc | |||
| 4806ac073a | |||
| ac9a5cda91 | |||
| 60e0a1755b | |||
| 9039356cfd | |||
| 4327cae06a | |||
| c22d175afb | |||
| 6650b8aaef | |||
| e18f31a8b4 | |||
| caeca5e050 | |||
| e44684049f | |||
| 359129c0dd | |||
| e53df719c7 | |||
| 53c5ad4f14 | |||
| dd59381b35 | |||
| 5811ece08e | |||
| 0701424b3c | |||
| 93d3125013 | |||
| e96979d36f | |||
| 9a17d9085c | |||
| 6c3ac6c09b | |||
| d1c34bd607 | |||
| 869ba964ee | |||
| 4d45de372b | |||
| 18adc04801 | |||
| c71dd3e680 | |||
| e978459c19 | |||
| 5f7106d257 | |||
| 1f2b8091f1 | |||
| 932a89193e | |||
| 048832589c | |||
| f55ec7c525 | |||
| d3fce38235 | |||
| 9183769ff4 | |||
| 4f4960280a | |||
| c85a428a7b | |||
| 1738a38b4f | |||
| ddd7c5a9d6 | |||
| a141f1822c | |||
| 41fab74f11 | |||
| df1c5d9acf | |||
| a4b691f8be | |||
| ac82436751 | |||
| 868d6f3665 | |||
| bd965caa4c | |||
| 145eca557e | |||
| 25e3ea95ba | |||
| b6683158d9 | |||
| 3efc019322 | |||
| a980e4eff2 | |||
| 324d21ec07 | |||
| 1224d4801d | |||
| 51b8753875 | |||
| b31e90b052 | |||
| 2d5cc896b8 | |||
| 975c9c5a08 | |||
| 57d6c503f2 | |||
| e3b71dbd4d |
@@ -4,7 +4,7 @@ A firka androidra való lebuildeléséhez kötelező a saját Flutter fork haszn
|
|||||||
|
|
||||||
A Flutter telepítéséhez a dokumentáció [itt](https://docs.flutter.dev/get-started/install) található.
|
A Flutter telepítéséhez a dokumentáció [itt](https://docs.flutter.dev/get-started/install) található.
|
||||||
|
|
||||||
A Flutter zip letöltése helyett [a custom engine zip-et töltsd le](https://git.firka.app/firka/flutter/archive/main.zip)
|
A Flutter zip letöltése helyett a custom engine-t cloneold le ([https://git.firka.app/firka/flutter/](https://git.firka.app/firka/flutter/))
|
||||||
|
|
||||||
# Brotli
|
# Brotli
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ and to make a release build you will have to use our custom
|
|||||||
flutter engine.
|
flutter engine.
|
||||||
The documentation for installing flutter can be found [here](https://docs.flutter.dev/get-started/install).
|
The documentation for installing flutter can be found [here](https://docs.flutter.dev/get-started/install).
|
||||||
|
|
||||||
Instead of downloading the regular flutter zip, download it from [here](https://git.firka.app/firka/flutter/archive/main.zip).
|
Instead of downloading the regular flutter zip, clone it from ([https://git.firka.app/firka/flutter/](https://git.firka.app/firka/flutter/)).
|
||||||
|
|
||||||
# Brotli
|
# Brotli
|
||||||
|
|
||||||
|
|||||||
108
Jenkinsfile
vendored
@@ -16,6 +16,7 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Decrypt main keys') {
|
stage('Decrypt main keys') {
|
||||||
when {
|
when {
|
||||||
branch 'main'
|
branch 'main'
|
||||||
@@ -40,6 +41,7 @@ pipeline {
|
|||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Clone submodules') {
|
stage('Clone submodules') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
@@ -47,36 +49,13 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stage('Modify firka_bundle.dart') {
|
|
||||||
when {
|
|
||||||
branch 'main'
|
|
||||||
}
|
|
||||||
steps {
|
|
||||||
script {
|
|
||||||
sh '''#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
BUNDLE_FILE="firka/lib/helpers/firka_bundle.dart"
|
|
||||||
|
|
||||||
if [ -f "$BUNDLE_FILE" ]; then
|
|
||||||
echo "Modifying $BUNDLE_FILE"
|
|
||||||
sed -i 's/final bool _compressedBundle = false;/final bool _compressedBundle = Platform.isAndroid;/' "$BUNDLE_FILE"
|
|
||||||
echo "Modified _compressedBundle setting"
|
|
||||||
|
|
||||||
grep "_compressedBundle" "$BUNDLE_FILE" || echo "Warning: _compressedBundle line not found after modification"
|
|
||||||
else
|
|
||||||
echo "$BUNDLE_FILE not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
'''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stage('Build firka') {
|
stage('Build firka') {
|
||||||
steps {
|
steps {
|
||||||
sh 'bash -c "./tools/linux/build_apk.sh ' + env.BRANCH_NAME + '"'
|
sh 'bash -c "./tools/linux/build_apk.sh ' + env.BRANCH_NAME + '"'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Rename Release APKs') {
|
stage('Rename Release APKs') {
|
||||||
when {
|
when {
|
||||||
branch 'main'
|
branch 'main'
|
||||||
@@ -109,6 +88,7 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Calculate Version Code') {
|
stage('Calculate Version Code') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
@@ -118,7 +98,7 @@ pipeline {
|
|||||||
|
|
||||||
# Calculate version code based on git commits (same logic as build script)
|
# Calculate version code based on git commits (same logic as build script)
|
||||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||||
BASE_BUILD_NUMBER=$((1000 + COMMIT_COUNT))
|
BASE_BUILD_NUMBER=$((1300 + COMMIT_COUNT))
|
||||||
|
|
||||||
if [ "$BRANCH_NAME" = "main" ]; then
|
if [ "$BRANCH_NAME" = "main" ]; then
|
||||||
# For main branch, highest version code is BASE + 3000 (x64 build)
|
# For main branch, highest version code is BASE + 3000 (x64 build)
|
||||||
@@ -137,75 +117,43 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stage('Upload to F-Droid Debug') {
|
|
||||||
|
stage('Publish debug artifacts') {
|
||||||
when {
|
when {
|
||||||
branch 'dev'
|
not {
|
||||||
}
|
branch 'main'
|
||||||
steps {
|
|
||||||
script {
|
|
||||||
withCredentials([usernamePassword(credentialsId: 'fdroid-ssh', usernameVariable: 'SSH_USER', passwordVariable: 'SSHPASS')]) {
|
|
||||||
sh '''
|
|
||||||
SOURCE_FILE="firka/build/app/outputs/flutter-apk/app-debug.apk"
|
|
||||||
REMOTE_PATH="/home/fdroid/firka-fdroid/repo/app.firka.naplo.debug.apk"
|
|
||||||
export SSHPASS
|
|
||||||
|
|
||||||
sshpass -e scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
||||||
"$SOURCE_FILE" "$SSH_USER@10.0.0.21:$REMOTE_PATH"
|
|
||||||
|
|
||||||
# Update version code in F-Droid metadata
|
|
||||||
sshpass -e ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
||||||
"$SSH_USER@10.0.0.21" \
|
|
||||||
"sed -i 's/^CurrentVersionCode: .*/CurrentVersionCode: $VERSION_CODE/' /home/fdroid/firka-fdroid/metadata/app.firka.naplo.debug.yml"
|
|
||||||
|
|
||||||
sshpass -e ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
||||||
"$SSH_USER@10.0.0.21" \
|
|
||||||
"cd /home/fdroid/firka-fdroid && /run/current-system/sw/bin/fdroid update"
|
|
||||||
'''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
steps {
|
||||||
|
archiveArtifacts artifacts: 'firka/build/app/outputs/flutter-apk/app-debug.apk', fingerprint: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
stage('Upload to F-Droid Release') {
|
|
||||||
|
stage('Publish release AABs artifacts') {
|
||||||
when {
|
when {
|
||||||
branch 'main'
|
branch 'main'
|
||||||
}
|
}
|
||||||
steps {
|
steps {
|
||||||
script {
|
archiveArtifacts artifacts: 'firka/build/app/outputs/bundle/release/*.aab', fingerprint: true
|
||||||
withCredentials([usernamePassword(credentialsId: 'fdroid-ssh', usernameVariable: 'SSH_USER', passwordVariable: 'SSHPASS')]) {
|
sh 'rm firka/build/app/outputs/bundle/release/*.aab'
|
||||||
sh '''
|
|
||||||
# Use the renamed APK files
|
|
||||||
REMOTE_PATH="/home/fdroid/firka-fdroid/repo/"
|
|
||||||
export SSHPASS
|
|
||||||
|
|
||||||
# Loop over each APK file and upload it one by one
|
|
||||||
for SOURCE_FILE in firka/build/app/outputs/flutter-apk/app.firka.naplo_*.apk; do
|
|
||||||
if [ -f "$SOURCE_FILE" ]; then
|
|
||||||
echo "Uploading $SOURCE_FILE to $REMOTE_PATH"
|
|
||||||
sshpass -e scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
||||||
"$SOURCE_FILE" "$SSH_USER@10.0.0.21:$REMOTE_PATH"
|
|
||||||
else
|
|
||||||
echo "No APK files found to upload."
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Update version code in F-Droid metadata for release
|
|
||||||
sshpass -e ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
||||||
"$SSH_USER@10.0.0.21" \
|
|
||||||
"sed -i 's/^CurrentVersionCode: .*/CurrentVersionCode: $VERSION_CODE/' /home/fdroid/firka-fdroid/metadata/app.firka.naplo.yml"
|
|
||||||
|
|
||||||
# Update F-Droid repository
|
|
||||||
sshpass -e ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
||||||
"$SSH_USER@10.0.0.21" \
|
|
||||||
"cd /home/fdroid/firka-fdroid && /run/current-system/sw/bin/fdroid update"
|
|
||||||
'''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stage('Publish release APKs artifacts') {
|
||||||
|
when {
|
||||||
|
branch 'main'
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
archiveArtifacts artifacts: 'firka/build/app/outputs/flutter-apk/app.firka.naplo_*.apk', fingerprint: true
|
||||||
|
sh 'rm firka/build/app/outputs/flutter-apk/app.firka.naplo_*.apk'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stage('Post Cleanup') {
|
stage('Post Cleanup') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
sh '''
|
sh '''
|
||||||
|
rm firka/build/app/outputs/bundle/release/*.aab || true
|
||||||
rm firka/build/app/outputs/flutter-apk/app.firka.naplo_*.apk || true
|
rm firka/build/app/outputs/flutter-apk/app.firka.naplo_*.apk || true
|
||||||
rm firka/build/app/outputs/flutter-apk/app-debug.apk || true
|
rm firka/build/app/outputs/flutter-apk/app-debug.apk || true
|
||||||
rm version_code.txt || true
|
rm version_code.txt || true
|
||||||
|
|||||||
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
|
||||||
5
firka/.gitignore
vendored
@@ -12,6 +12,11 @@
|
|||||||
.swiftpm/
|
.swiftpm/
|
||||||
migrate_working_dir/
|
migrate_working_dir/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
# IntelliJ related
|
# IntelliJ related
|
||||||
*.iml
|
*.iml
|
||||||
*.ipr
|
*.ipr
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# This file should be version controlled and should not be manually edited.
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
version:
|
version:
|
||||||
revision: "05db9689081f091050f01aed79f04dce0c750154"
|
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
|
|
||||||
project_type: app
|
project_type: app
|
||||||
@@ -13,11 +13,11 @@ project_type: app
|
|||||||
migration:
|
migration:
|
||||||
platforms:
|
platforms:
|
||||||
- platform: root
|
- platform: root
|
||||||
create_revision: 05db9689081f091050f01aed79f04dce0c750154
|
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
base_revision: 05db9689081f091050f01aed79f04dce0c750154
|
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
- platform: ios
|
- platform: ios
|
||||||
create_revision: 05db9689081f091050f01aed79f04dce0c750154
|
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
base_revision: 05db9689081f091050f01aed79f04dce0c750154
|
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
|
|||||||
@@ -685,6 +685,11 @@ fun getDebugKeystorePath(): String {
|
|||||||
fun getDefaultAndroidSdkPath(): String? {
|
fun getDefaultAndroidSdkPath(): String? {
|
||||||
val os = System.getProperty("os.name").lowercase()
|
val os = System.getProperty("os.name").lowercase()
|
||||||
val userHome = System.getProperty("user.home")
|
val userHome = System.getProperty("user.home")
|
||||||
|
val zipAlign = File("/usr/bin/zipalign")
|
||||||
|
|
||||||
|
if (zipAlign.exists()) {
|
||||||
|
return "/usr/bin"
|
||||||
|
}
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
os.contains("win") ->
|
os.contains("win") ->
|
||||||
@@ -740,6 +745,11 @@ fun findToolInSdkPath(toolName: String): String? {
|
|||||||
return toolExec.absolutePath
|
return toolExec.absolutePath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
val toolExec = File(androidHome, toolName)
|
||||||
|
if (toolExec.exists()) {
|
||||||
|
return toolExec.absolutePath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -883,4 +893,4 @@ fun signBundle(input: File, output: File) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println("AAB signed and aligned successfully")
|
println("AAB signed and aligned successfully")
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
firka/android/app/src/main/res/drawable-hdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
firka/android/app/src/main/res/drawable-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
firka/android/app/src/main/res/drawable-mdpi/android12splash.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
firka/android/app/src/main/res/drawable-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 14 KiB |
BIN
firka/android/app/src/main/res/drawable-night-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
BIN
firka/android/app/src/main/res/drawable-night-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
firka/android/app/src/main/res/drawable-night-v21/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
||||||
|
After Width: | Height: | Size: 16 KiB |
BIN
firka/android/app/src/main/res/drawable-night-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 24 KiB |
BIN
firka/android/app/src/main/res/drawable-night-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
BIN
firka/android/app/src/main/res/drawable-night-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
firka/android/app/src/main/res/drawable-night/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
||||||
BIN
firka/android/app/src/main/res/drawable-v21/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -1,12 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<item>
|
||||||
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
<!-- You can insert your own image assets here -->
|
</item>
|
||||||
<!-- <item>
|
<item>
|
||||||
<bitmap
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
android:gravity="center"
|
</item>
|
||||||
android:src="@mipmap/launch_image" />
|
|
||||||
</item> -->
|
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 16 KiB |
BIN
firka/android/app/src/main/res/drawable-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 24 KiB |
BIN
firka/android/app/src/main/res/drawable-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
BIN
firka/android/app/src/main/res/drawable-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
firka/android/app/src/main/res/drawable/background.png
Normal file
|
After Width: | Height: | Size: 69 B |
@@ -1,12 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<item>
|
||||||
|
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||||
<!-- You can insert your own image assets here -->
|
</item>
|
||||||
<!-- <item>
|
<item>
|
||||||
<bitmap
|
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||||
android:gravity="center"
|
</item>
|
||||||
android:src="@mipmap/launch_image" />
|
|
||||||
</item> -->
|
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
21
firka/android/app/src/main/res/values-night-v31/styles.xml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
|
<item name="android:windowFullscreen">false</item>
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
|
<item name="android:windowSplashScreenBackground">#7ca120</item>
|
||||||
|
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -5,6 +5,10 @@
|
|||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
|
<item name="android:windowFullscreen">false</item>
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
This theme determines the color of the Android Window while your
|
||||||
|
|||||||
21
firka/android/app/src/main/res/values-v31/styles.xml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
|
<item name="android:windowFullscreen">false</item>
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
|
<item name="android:windowSplashScreenBackground">#7ca120</item>
|
||||||
|
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -5,6 +5,10 @@
|
|||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
|
<item name="android:windowFullscreen">false</item>
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
This theme determines the color of the Android Window while your
|
||||||
|
|||||||
4
firka/assets/icons/group.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.44866 0.51265C7.82192 0.400678 8.21619 0.377515 8.59999 0.44501C8.98379 0.512504 9.3465 0.668787 9.65917 0.901383C9.97183 1.13398 10.2258 1.43645 10.4008 1.78464C10.5758 2.13284 10.6669 2.51712 10.667 2.90682V15.0935C10.6669 15.4832 10.5758 15.8675 10.4008 16.2157C10.2258 16.5639 9.97183 16.8663 9.65917 17.0989C9.3465 17.3315 8.98379 17.4878 8.59999 17.5553C8.21619 17.6228 7.82192 17.5996 7.44866 17.4876L2.44866 15.9876C1.93374 15.8332 1.48233 15.5169 1.16139 15.0856C0.840454 14.6543 0.66708 14.1311 0.666992 13.5935V4.40682C0.66708 3.86923 0.840454 3.34599 1.16139 2.91472C1.48233 2.48345 1.93374 2.16712 2.44866 2.01265L7.44866 0.51265ZM11.5003 2.33348C11.5003 2.11247 11.5881 1.90051 11.7444 1.74423C11.9007 1.58795 12.1126 1.50015 12.3337 1.50015H14.8337C15.4967 1.50015 16.1326 1.76354 16.6014 2.23238C17.0703 2.70122 17.3337 3.33711 17.3337 4.00015V4.83348C17.3337 5.0545 17.2459 5.26646 17.0896 5.42274C16.9333 5.57902 16.7213 5.66682 16.5003 5.66682C16.2793 5.66682 16.0673 5.57902 15.9111 5.42274C15.7548 5.26646 15.667 5.0545 15.667 4.83348V4.00015C15.667 3.77914 15.5792 3.56717 15.4229 3.41089C15.2666 3.25461 15.0547 3.16682 14.8337 3.16682H12.3337C12.1126 3.16682 11.9007 3.07902 11.7444 2.92274C11.5881 2.76646 11.5003 2.5545 11.5003 2.33348ZM16.5003 12.3335C16.7213 12.3335 16.9333 12.4213 17.0896 12.5776C17.2459 12.7338 17.3337 12.9458 17.3337 13.1668V14.0001C17.3337 14.6632 17.0703 15.2991 16.6014 15.7679C16.1326 16.2368 15.4967 16.5001 14.8337 16.5001H12.3337C12.1126 16.5001 11.9007 16.4124 11.7444 16.2561C11.5881 16.0998 11.5003 15.8878 11.5003 15.6668C11.5003 15.4458 11.5881 15.2338 11.7444 15.0776C11.9007 14.9213 12.1126 14.8335 12.3337 14.8335H14.8337C15.0547 14.8335 15.2666 14.7457 15.4229 14.5894C15.5792 14.4331 15.667 14.2212 15.667 14.0001V13.1668C15.667 12.9458 15.7548 12.7338 15.9111 12.5776C16.0673 12.4213 16.2793 12.3335 16.5003 12.3335ZM6.50033 8.16682C6.27931 8.16682 6.06735 8.25461 5.91107 8.41089C5.75479 8.56717 5.66699 8.77914 5.66699 9.00015C5.66699 9.22116 5.75479 9.43312 5.91107 9.58941C6.06735 9.74569 6.27931 9.83348 6.50033 9.83348H6.50116C6.72217 9.83348 6.93413 9.74569 7.09041 9.58941C7.2467 9.43312 7.33449 9.22116 7.33449 9.00015C7.33449 8.77914 7.2467 8.56717 7.09041 8.41089C6.93413 8.25461 6.72217 8.16682 6.50116 8.16682H6.50033Z" fill="#A7DC22"/>
|
||||||
|
<path d="M12.334 8.99967H16.5007M16.5007 8.99967L14.834 7.33301M16.5007 8.99967L14.834 10.6663" stroke="#A7DC22" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
3
firka/assets/icons/subtract.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="17" height="18" viewBox="0 0 17 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.6372 4.23604C15.047 1.69217 12.2211 0 9 0C4.02944 0 0 4.02944 0 9C0 13.9706 4.02944 18 9 18C12.2211 18 15.047 16.3078 16.6372 13.764C16.2218 12.2466 16 10.6492 16 9C16 7.35081 16.2218 5.75343 16.6372 4.23604Z" fill="#000000" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 384 B |
3
firka/assets/images/bubble.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0 8C0 3.58172 3.58172 0 8 0V0C12.4183 0 16 3.58172 16 8V8C16 12.4183 12.4183 16 8 16H2C0.895431 16 0 15.1046 0 14V8Z" fill="#000000"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 248 B |
BIN
firka/assets/images/cactus_error_screen.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
firka/assets/images/carousel_dark/slide1.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
firka/assets/images/carousel_dark/slide2.webp
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
firka/assets/images/carousel_dark/slide3.webp
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
firka/assets/images/carousel_dark/slide4.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
firka/assets/images/logos/dave_error.png
Normal file
|
After Width: | Height: | Size: 329 KiB |
369
firka/assets/swears/DirtyWords.xml
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- https://github.com/stifolder/kretainsult/blob/master/src/assets/dirtywords.xml -->
|
||||||
|
<!-- Köszönjük a kretainsult.online eredeti alkotójának a kategorizálást! -->
|
||||||
|
<DirtyWords>
|
||||||
|
<Word type="m">Aberált</Word>
|
||||||
|
<Word type="m">Aberrált</Word>
|
||||||
|
<Word type="f">Abortuszmaradék</Word>
|
||||||
|
<Word type="m">Abszolút hülye</Word>
|
||||||
|
<Word type="m">Agyalágyult</Word>
|
||||||
|
<Word type="m">Agyatlan</Word>
|
||||||
|
<Word type="m">Agybatetovált</Word>
|
||||||
|
<Word type="m">Ágybavizelős</Word>
|
||||||
|
<Word type="f">Agyfasz</Word>
|
||||||
|
<Word type="f">Agyhalott</Word>
|
||||||
|
<Word type="m">Agyonkúrt</Word>
|
||||||
|
<Word type="m">Agyonvert</Word>
|
||||||
|
<Word type="m">Agyrákos</Word>
|
||||||
|
<Word type="m">AIDS-es</Word>
|
||||||
|
<Word type="m">Alapvetően fasz</Word>
|
||||||
|
<Word type="f">Animalsex-mániás</Word>
|
||||||
|
<Word type="f">Antibarom</Word>
|
||||||
|
<Word type="m">Aprófaszú</Word>
|
||||||
|
<Word type="m">Arcbarakott</Word>
|
||||||
|
<Word type="m">Aszaltfaszú</Word>
|
||||||
|
<Word type="m">Aszott</Word>
|
||||||
|
<Word type="m">Átbaszott</Word>
|
||||||
|
<Word type="f">Azt a kurva de fasz</Word>
|
||||||
|
<Word type="m">Balatonberényben napvilágot látott</Word>
|
||||||
|
<Word type="f">Balfasz</Word>
|
||||||
|
<Word type="f">Balfészek</Word>
|
||||||
|
<Word type="f">Baromfifasz</Word>
|
||||||
|
<Word type="f">Basz-o-matic</Word>
|
||||||
|
<Word type="m">Baszhatatlan</Word>
|
||||||
|
<Word type="m">Basznivaló</Word>
|
||||||
|
<Word type="m">Bebaszott</Word>
|
||||||
|
<Word type="m">Befosi</Word>
|
||||||
|
<Word type="m">Békapicsa</Word>
|
||||||
|
<Word type="m">Bélböfi</Word>
|
||||||
|
<Word type="m">Beleiből kiforgatott</Word>
|
||||||
|
<Word type="f">Bélszél</Word>
|
||||||
|
<Word type="m">Bronz térdű</Word>
|
||||||
|
<Word type="f">Brunya</Word>
|
||||||
|
<Word type="m">Büdös szájú</Word>
|
||||||
|
<Word type="m">Büdösszájú</Word>
|
||||||
|
<Word type="m">Búvalbaszott</Word>
|
||||||
|
<Word type="f">Buzeráns</Word>
|
||||||
|
<Word type="m">Buzernyák</Word>
|
||||||
|
<Word type="f">Buzi</Word>
|
||||||
|
<Word type="f">Buzikurva</Word>
|
||||||
|
<Word type="f">Cafat</Word>
|
||||||
|
<Word type="f">Cafka</Word>
|
||||||
|
<Word type="f">Céda</Word>
|
||||||
|
<Word type="m">Cérnafaszú</Word>
|
||||||
|
<Word type="f">Cottonfej</Word>
|
||||||
|
<Word type="m">Csempe szobában felneveltetett</Word>
|
||||||
|
<Word type="m">Cseszett</Word>
|
||||||
|
<Word type="f">Csibefasz</Word>
|
||||||
|
<Word type="f">Csipszar</Word>
|
||||||
|
<Word type="m">Csirkefaszú</Word>
|
||||||
|
<Word type="f">Csitri</Word>
|
||||||
|
<Word type="f">Csöcs</Word>
|
||||||
|
<Word type="f">Csöcsfej</Word>
|
||||||
|
<Word type="f">Csöppszar</Word>
|
||||||
|
<Word type="m">Csőszkunyhóban elrejtett</Word>
|
||||||
|
<Word type="m">Csupaszfarkú</Word>
|
||||||
|
<Word type="f">Cuncipunci</Word>
|
||||||
|
<Word type="m">Deformáltfaszú</Word>
|
||||||
|
<Word type="m">Dekorált pofájú</Word>
|
||||||
|
<Word type="m">Döbbenetesen segg</Word>
|
||||||
|
<Word type="m">Dobseggű</Word>
|
||||||
|
<Word type="m">Dughatatlan</Word>
|
||||||
|
<Word type="m">Dunyhavalagú</Word>
|
||||||
|
<Word type="m">Duplafaszú</Word>
|
||||||
|
<Word type="f">Ebfasz</Word>
|
||||||
|
<Word type="m">Egyszerűen fasz</Word>
|
||||||
|
<Word type="m">Elbaszott</Word>
|
||||||
|
<Word type="m">Eleve hülye</Word>
|
||||||
|
<Word type="m">Extrahülye</Word>
|
||||||
|
<Word type="m">Fafogú rézfűrésszel megsebzett</Word>
|
||||||
|
<Word type="m">Fantasztikusan segg</Word>
|
||||||
|
<Word type="f">Fasszopó</Word>
|
||||||
|
<Word type="m">Fasz</Word>
|
||||||
|
<Word type="m">Fasz-emulátor</Word>
|
||||||
|
<Word type="m">Faszagyú</Word>
|
||||||
|
<Word type="f">Faszarc</Word>
|
||||||
|
<Word type="f">Faszfej</Word>
|
||||||
|
<Word type="f">Faszfészek</Word>
|
||||||
|
<Word type="f">Faszkalap</Word>
|
||||||
|
<Word type="f">Faszkarika</Word>
|
||||||
|
<Word type="m">Faszkedvelő</Word>
|
||||||
|
<Word type="f">Faszkópé</Word>
|
||||||
|
<Word type="f">Faszogány</Word>
|
||||||
|
<Word type="f">Faszpörgettyű</Word>
|
||||||
|
<Word type="f">Faszsapka</Word>
|
||||||
|
<Word type="m">Faszszagú</Word>
|
||||||
|
<Word type="f">Faszszopó</Word>
|
||||||
|
<Word type="m">Fasztalan</Word>
|
||||||
|
<Word type="f">Fasztarisznya</Word>
|
||||||
|
<Word type="f">Fasztengely</Word>
|
||||||
|
<Word type="f">Fasztolvaj</Word>
|
||||||
|
<Word type="f">Faszváladék</Word>
|
||||||
|
<Word type="f">Faszverő</Word>
|
||||||
|
<Word type="m">Félrebaszott</Word>
|
||||||
|
<Word type="m">Félrefingott</Word>
|
||||||
|
<Word type="m">Félreszart</Word>
|
||||||
|
<Word type="f">Félribanc</Word>
|
||||||
|
<Word type="f">Fing</Word>
|
||||||
|
<Word type="m">Fölcsinált</Word>
|
||||||
|
<Word type="m">Fölfingott</Word>
|
||||||
|
<Word type="f">Fos</Word>
|
||||||
|
<Word type="f">Foskemence</Word>
|
||||||
|
<Word type="f">Fospisztoly</Word>
|
||||||
|
<Word type="f">Fospumpa</Word>
|
||||||
|
<Word type="f">Fostalicska</Word>
|
||||||
|
<Word type="f">Fütyi</Word>
|
||||||
|
<Word type="m">Fütyinyalogató</Word>
|
||||||
|
<Word type="f">Fütykös</Word>
|
||||||
|
<Word type="f">Geci</Word>
|
||||||
|
<Word type="m">Gecinyelő</Word>
|
||||||
|
<Word type="m">Geciszaró</Word>
|
||||||
|
<Word type="m">Geciszívó</Word>
|
||||||
|
<Word type="f">Genny</Word>
|
||||||
|
<Word type="m">Gennyesszájú</Word>
|
||||||
|
<Word type="f">Gennygóc</Word>
|
||||||
|
<Word type="f">Genyac</Word>
|
||||||
|
<Word type="f">Genyó</Word>
|
||||||
|
<Word type="f">Gólyafos</Word>
|
||||||
|
<Word type="m">Görbefaszú</Word>
|
||||||
|
<Word type="m">Gyennyszopó</Word>
|
||||||
|
<Word type="f">Gyíkfing</Word>
|
||||||
|
<Word type="f">Hájpacni</Word>
|
||||||
|
<Word type="f">Hatalmas nagy fasz</Word>
|
||||||
|
<Word type="m">Hátbabaszott</Word>
|
||||||
|
<Word type="f">Házikurva</Word>
|
||||||
|
<Word type="m">Hererákos</Word>
|
||||||
|
<Word type="m">Hígagyú</Word>
|
||||||
|
<Word type="m">Hihetetlenül fasz</Word>
|
||||||
|
<Word type="f">Hikomat</Word>
|
||||||
|
<Word type="f">Hímnőstény</Word>
|
||||||
|
<Word type="f">Hímringyó</Word>
|
||||||
|
<Word type="m">Hiperstrici</Word>
|
||||||
|
<Word type="m">Hitler-imádó</Word>
|
||||||
|
<Word type="m">Hitlerista</Word>
|
||||||
|
<Word type="f">Hivatásos balfasz</Word>
|
||||||
|
<Word type="m">Hú de segg</Word>
|
||||||
|
<Word type="m">Hugyagyú</Word>
|
||||||
|
<Word type="m">Hugyos</Word>
|
||||||
|
<Word type="f">Hugytócsa</Word>
|
||||||
|
<Word type="m">Hüje</Word>
|
||||||
|
<Word type="m">Hüle</Word>
|
||||||
|
<Word type="m">Hülye</Word>
|
||||||
|
<Word type="m">Hülyécske</Word>
|
||||||
|
<Word type="f">Hülyegyerek</Word>
|
||||||
|
<Word type="f">Inkubátor-szökevény</Word>
|
||||||
|
<Word type="f">Integrált barom</Word>
|
||||||
|
<Word type="m">Ionizált faszú</Word>
|
||||||
|
<Word type="f">IQ bajnok</Word>
|
||||||
|
<Word type="f">IQ fighter</Word>
|
||||||
|
<Word type="m">IQ hiányos</Word>
|
||||||
|
<Word type="m">Irdatlanul köcsög</Word>
|
||||||
|
<Word type="m">Íveltfaszú</Word>
|
||||||
|
<Word type="m">Jajj de barom</Word>
|
||||||
|
<Word type="m">Jókora fasz</Word>
|
||||||
|
<Word type="f">Kaka</Word>
|
||||||
|
<Word type="f">Kakamatyi</Word>
|
||||||
|
<Word type="f">Kaki</Word>
|
||||||
|
<Word type="f">Kaksi</Word>
|
||||||
|
<Word type="m">Kecskebaszó</Word>
|
||||||
|
<Word type="m">Kellően fasz</Word>
|
||||||
|
<Word type="m">Képlékeny faszú</Word>
|
||||||
|
<Word type="f">Keresve sem található fasz</Word>
|
||||||
|
<Word type="m">Kétfaszú</Word>
|
||||||
|
<Word type="m">Kétszer agyonbaszott</Word>
|
||||||
|
<Word type="m">Ki-bebaszott</Word>
|
||||||
|
<Word type="m">Kibaszott</Word>
|
||||||
|
<Word type="m">Kifingott</Word>
|
||||||
|
<Word type="m">Kiherélt</Word>
|
||||||
|
<Word type="m">Kikakkantott</Word>
|
||||||
|
<Word type="m">Kikészült</Word>
|
||||||
|
<Word type="m">Kimagaslóan fasz</Word>
|
||||||
|
<Word type="m">Kimondhatatlan pöcs</Word>
|
||||||
|
<Word type="f">Kis szaros</Word>
|
||||||
|
<Word type="f">Kisfütyi</Word>
|
||||||
|
<Word type="m">Klotyószagú</Word>
|
||||||
|
<Word type="m">Ködmönbe bújtatott</Word>
|
||||||
|
<Word type="m">Kojak-faszú</Word>
|
||||||
|
<Word type="m">Kopárfaszú</Word>
|
||||||
|
<Word type="m">Korlátolt gecizésű</Word>
|
||||||
|
<Word type="f">Kotonszökevény</Word>
|
||||||
|
<Word type="m">Középszar</Word>
|
||||||
|
<Word type="f">Kretén</Word>
|
||||||
|
<Word type="f">Kuki</Word>
|
||||||
|
<Word type="f">Kula</Word>
|
||||||
|
<Word type="m">Kunkorított faszú</Word>
|
||||||
|
<Word type="f">Kurva</Word>
|
||||||
|
<Word type="m">Kurvaanyjú</Word>
|
||||||
|
<Word type="f">Kurvapecér</Word>
|
||||||
|
<Word type="f">Kutyakaki</Word>
|
||||||
|
<Word type="f">Kutyapina</Word>
|
||||||
|
<Word type="f">Kutyaszar</Word>
|
||||||
|
<Word type="m">Lankadtfaszú</Word>
|
||||||
|
<Word type="m">Lebaszirgált</Word>
|
||||||
|
<Word type="m">Lebaszott</Word>
|
||||||
|
<Word type="m">Lecseszett</Word>
|
||||||
|
<Word type="m">Leírhatatlanul segg</Word>
|
||||||
|
<Word type="m">Lemenstruált</Word>
|
||||||
|
<Word type="m">Leokádott</Word>
|
||||||
|
<Word type="f">Lepkefing</Word>
|
||||||
|
<Word type="f">Leprafészek</Word>
|
||||||
|
<Word type="m">Leszart</Word>
|
||||||
|
<Word type="m">Leszbikus</Word>
|
||||||
|
<Word type="f">Lőcs</Word>
|
||||||
|
<Word type="f">Lőcsgéza</Word>
|
||||||
|
<Word type="f">Lófasz</Word>
|
||||||
|
<Word type="m">Lógócsöcsű</Word>
|
||||||
|
<Word type="f">Lóhugy</Word>
|
||||||
|
<Word type="f">Lotyó</Word>
|
||||||
|
<Word type="m">Lucskos</Word>
|
||||||
|
<Word type="f">Lugnya</Word>
|
||||||
|
<Word type="m">Lyukasbelű</Word>
|
||||||
|
<Word type="m">Lyukasfaszú</Word>
|
||||||
|
<Word type="m">Lyukát vakaró</Word>
|
||||||
|
<Word type="m">Lyuktalanított</Word>
|
||||||
|
<Word type="f">Mamutsegg</Word>
|
||||||
|
<Word type="f">Maszturbációs görcs</Word>
|
||||||
|
<Word type="f">Maszturbagép</Word>
|
||||||
|
<Word type="m">Maszturbáltatott</Word>
|
||||||
|
<Word type="m">Megfingatott</Word>
|
||||||
|
<Word type="m">Megkettyintett</Word>
|
||||||
|
<Word type="m">Megkúrt</Word>
|
||||||
|
<Word type="m">Megszopatott</Word>
|
||||||
|
<Word type="m">Mesterséges faszú</Word>
|
||||||
|
<Word type="f">Méteres kékeres</Word>
|
||||||
|
<Word type="m">Mikrotökű</Word>
|
||||||
|
<Word type="m">Mocskos</Word>
|
||||||
|
<Word type="f">Mojfing</Word>
|
||||||
|
<Word type="m">Műfaszú</Word>
|
||||||
|
<Word type="f">Muff</Word>
|
||||||
|
<Word type="f">Multifasz</Word>
|
||||||
|
<Word type="m">Műtöttpofájú</Word>
|
||||||
|
<Word type="m">Náci</Word>
|
||||||
|
<Word type="m">Nagyfejű</Word>
|
||||||
|
<Word type="f">Nikotinpatkány</Word>
|
||||||
|
<Word type="m">Nimfomániás</Word>
|
||||||
|
<Word type="f">Nuna</Word>
|
||||||
|
<Word type="f">Nunci</Word>
|
||||||
|
<Word type="f">Nuncóka</Word>
|
||||||
|
<Word type="f">Nyalábfasz</Word>
|
||||||
|
<Word type="f">Nyelestojás</Word>
|
||||||
|
<Word type="f">Nyúlszar</Word>
|
||||||
|
<Word type="f">Oltári nagy fasz</Word>
|
||||||
|
<Word type="m">Ondónyelő</Word>
|
||||||
|
<Word type="m">Orbitálisan hülye</Word>
|
||||||
|
<Word type="m">Ordenálé</Word>
|
||||||
|
<Word type="m">Összebaszott</Word>
|
||||||
|
<Word type="f">Ötcsillagos fasz</Word>
|
||||||
|
<Word type="m">Óvszerezett</Word>
|
||||||
|
<Word type="f">Pénisz</Word>
|
||||||
|
<Word type="m">Peremesfaszú</Word>
|
||||||
|
<Word type="f">Picsa</Word>
|
||||||
|
<Word type="f">Picsafej</Word>
|
||||||
|
<Word type="m">Picsameresztő</Word>
|
||||||
|
<Word type="m">Picsánnyalt</Word>
|
||||||
|
<Word type="m">Picsánrugott</Word>
|
||||||
|
<Word type="m">Picsányi</Word>
|
||||||
|
<Word type="m">Pikkelypáncélt hordó</Word>
|
||||||
|
<Word type="f">Pina</Word>
|
||||||
|
<Word type="f">Pisa</Word>
|
||||||
|
<Word type="m">Pisaszagú</Word>
|
||||||
|
<Word type="m">Pisis</Word>
|
||||||
|
<Word type="f">Pöcs</Word>
|
||||||
|
<Word type="f">Pöcsfej</Word>
|
||||||
|
<Word type="m">Porbafingó</Word>
|
||||||
|
<Word type="f">Pornóbuzi</Word>
|
||||||
|
<Word type="m">Pornómániás</Word>
|
||||||
|
<Word type="m">Pudvás</Word>
|
||||||
|
<Word type="m">Pudváslikú</Word>
|
||||||
|
<Word type="m">Puhafaszú</Word>
|
||||||
|
<Word type="f">Punci</Word>
|
||||||
|
<Word type="f">Puncimókus</Word>
|
||||||
|
<Word type="m">Puncis</Word>
|
||||||
|
<Word type="f">Punciutáló</Word>
|
||||||
|
<Word type="f">Puncivirág</Word>
|
||||||
|
<Word type="f">Qki</Word>
|
||||||
|
<Word type="f">Qrva</Word>
|
||||||
|
<Word type="f">Qtyaszar</Word>
|
||||||
|
<Word type="m">Rabló</Word>
|
||||||
|
<Word type="m">Rágcsáltfaszú</Word>
|
||||||
|
<Word type="f">Redva</Word>
|
||||||
|
<Word type="m">Rendkívül fasz</Word>
|
||||||
|
<Word type="m">Repedtsarkú</Word>
|
||||||
|
<Word type="m">Rétó-román</Word>
|
||||||
|
<Word type="m">Rézhasú</Word>
|
||||||
|
<Word type="f">Ribanc</Word>
|
||||||
|
<Word type="f">Riherongy</Word>
|
||||||
|
<Word type="m">Ritka fogú</Word>
|
||||||
|
<Word type="m">Rivalizáló</Word>
|
||||||
|
<Word type="f">Rőfös fasz</Word>
|
||||||
|
<Word type="m">Rojtospicsájú</Word>
|
||||||
|
<Word type="m">Rongyospinájú</Word>
|
||||||
|
<Word type="m">Roppant hülye</Word>
|
||||||
|
<Word type="f">Rossz kurva</Word>
|
||||||
|
<Word type="m">Saját nemével kefélő</Word>
|
||||||
|
<Word type="f">Segg</Word>
|
||||||
|
<Word type="f">Seggarc</Word>
|
||||||
|
<Word type="f">Seggdugó</Word>
|
||||||
|
<Word type="f">Seggfej</Word>
|
||||||
|
<Word type="f">Seggnyaló</Word>
|
||||||
|
<Word type="f">Seggszőr</Word>
|
||||||
|
<Word type="f">Seggtorlasz</Word>
|
||||||
|
<Word type="m">Sikoltozásokba öltöztetett</Word>
|
||||||
|
<Word type="f">Strici</Word>
|
||||||
|
<Word type="m">Suttyó</Word>
|
||||||
|
<Word type="m">Sutyerák</Word>
|
||||||
|
<Word type="m">Szálkafaszú</Word>
|
||||||
|
<Word type="f">Szar</Word>
|
||||||
|
<Word type="f">Szaralak</Word>
|
||||||
|
<Word type="f">Szárazfing</Word>
|
||||||
|
<Word type="f">Szarbojler</Word>
|
||||||
|
<Word type="f">Szarcsimbók</Word>
|
||||||
|
<Word type="m">Szarevő</Word>
|
||||||
|
<Word type="m">Szarfaszú</Word>
|
||||||
|
<Word type="f">Szarházi</Word>
|
||||||
|
<Word type="f">Szarjankó</Word>
|
||||||
|
<Word type="m">Szarnivaló</Word>
|
||||||
|
<Word type="m">Szarosvalagú</Word>
|
||||||
|
<Word type="m">Szarrá vágott</Word>
|
||||||
|
<Word type="f">Szarrágó</Word>
|
||||||
|
<Word type="m">Szarszagú</Word>
|
||||||
|
<Word type="m">Szarszájú</Word>
|
||||||
|
<Word type="f">Szartragacs</Word>
|
||||||
|
<Word type="f">Szarzsák</Word>
|
||||||
|
<Word type="f">Szégyencsicska</Word>
|
||||||
|
<Word type="m">Szifiliszes</Word>
|
||||||
|
<Word type="f">Szivattyús kurva</Word>
|
||||||
|
<Word type="m">Szófosó</Word>
|
||||||
|
<Word type="m">Szokatlanul fasz</Word>
|
||||||
|
<Word type="f">Szop-o-matic</Word>
|
||||||
|
<Word type="f">Szopógép</Word>
|
||||||
|
<Word type="f">Szopógörcs</Word>
|
||||||
|
<Word type="f">Szopós kurva</Word>
|
||||||
|
<Word type="m">Szopottfarkú</Word>
|
||||||
|
<Word type="m">Szűklyukú</Word>
|
||||||
|
<Word type="m">Szultán udvarát megjárt</Word>
|
||||||
|
<Word type="f">Szúnyogfaszni</Word>
|
||||||
|
<Word type="f">Szuperbuzi</Word>
|
||||||
|
<Word type="f">Szuperkurva</Word>
|
||||||
|
<Word type="m">Szűzhártya-repedéses</Word>
|
||||||
|
<Word type="f">Szűzkurva</Word>
|
||||||
|
<Word type="f">Szűzpicsa</Word>
|
||||||
|
<Word type="f">Szűzpunci</Word>
|
||||||
|
<Word type="m">Tetves</Word>
|
||||||
|
<Word type="f">Tikfos</Word>
|
||||||
|
<Word type="f">Tikszar</Word>
|
||||||
|
<Word type="m">Tompatökű</Word>
|
||||||
|
<Word type="m">Törpefaszú</Word>
|
||||||
|
<Word type="m">Toszatlan</Word>
|
||||||
|
<Word type="m">Toszott</Word>
|
||||||
|
<Word type="m">Totálisan hülye</Word>
|
||||||
|
<Word type="m">Tyű de picsa</Word>
|
||||||
|
<Word type="m">Tyúkfasznyi</Word>
|
||||||
|
<Word type="f">Tyúkszar</Word>
|
||||||
|
<Word type="f">Vadfasz</Word>
|
||||||
|
<Word type="f">Valag</Word>
|
||||||
|
<Word type="f">Valagváladék</Word>
|
||||||
|
<Word type="f">Végbélféreg</Word>
|
||||||
|
<Word type="f">Xar</Word>
|
||||||
|
<Word type="m">Zsugorított faszú</Word>
|
||||||
|
</DirtyWords>
|
||||||
16
firka/flutter_native_splash.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
flutter_native_splash:
|
||||||
|
color: "#7ca120"
|
||||||
|
image: assets/images/logos/splash.png
|
||||||
|
|
||||||
|
# Dark mode - same color as light mode for consistency
|
||||||
|
color_dark: "#7ca120"
|
||||||
|
image_dark: assets/images/logos/splash.png
|
||||||
|
|
||||||
|
android_12:
|
||||||
|
image: assets/images/logos/splash.png
|
||||||
|
color: "#7ca120"
|
||||||
|
color_dark: "#7ca120"
|
||||||
|
image_dark: assets/images/logos/splash.png
|
||||||
|
|
||||||
|
ios: true
|
||||||
|
web: false
|
||||||
5
firka/ios/.gitignore
vendored
@@ -33,4 +33,7 @@ Runner/GeneratedPluginRegistrant.*
|
|||||||
!default.pbxuser
|
!default.pbxuser
|
||||||
!default.perspectivev3
|
!default.perspectivev3
|
||||||
|
|
||||||
/.DerivedData
|
/.DerivedData
|
||||||
|
|
||||||
|
# Developer-specific configuration
|
||||||
|
.dev_config
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CountdownRing: View {
|
||||||
|
let totalMinutes: Int
|
||||||
|
let remainingMinutes: Int
|
||||||
|
let label: String
|
||||||
|
var size: CGFloat = 80
|
||||||
|
var lineWidth: CGFloat = 8
|
||||||
|
var displayOffset: Int = 0 // Add to displayed minutes (e.g., +1)
|
||||||
|
|
||||||
|
private var clampedRemainingMinutes: Int {
|
||||||
|
guard totalMinutes > 0 else { return 0 }
|
||||||
|
return max(0, min(remainingMinutes, totalMinutes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var progress: Double {
|
||||||
|
guard totalMinutes > 0 else { return 0 }
|
||||||
|
return Double(totalMinutes - clampedRemainingMinutes) / Double(totalMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayedMinutes: Int {
|
||||||
|
max(0, remainingMinutes + displayOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ringColor: Color {
|
||||||
|
if clampedRemainingMinutes < 5 { return .red }
|
||||||
|
if clampedRemainingMinutes < 10 { return .yellow }
|
||||||
|
return .green
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(Color(white: 0.2), lineWidth: lineWidth)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: progress)
|
||||||
|
.stroke(ringColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.animation(.easeInOut, value: progress)
|
||||||
|
|
||||||
|
VStack(spacing: 1) {
|
||||||
|
Text("\(displayedMinutes)")
|
||||||
|
.font(size > 60 ? .title2 : .headline)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
CountdownRing(totalMinutes: 45, remainingMinutes: 30, label: "min")
|
||||||
|
|
||||||
|
CountdownRing(totalMinutes: 45, remainingMinutes: 8, label: "min")
|
||||||
|
|
||||||
|
CountdownRing(totalMinutes: 45, remainingMinutes: 3, label: "min")
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
42
firka/ios/FirkaWatch Watch App/Components/FirkaCard.swift
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FirkaCard<Content: View>: View {
|
||||||
|
let content: Content
|
||||||
|
var isHighlighted: Bool = false
|
||||||
|
var backgroundColor: Color? = nil
|
||||||
|
|
||||||
|
init(
|
||||||
|
isHighlighted: Bool = false,
|
||||||
|
backgroundColor: Color? = nil,
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) {
|
||||||
|
self.isHighlighted = isHighlighted
|
||||||
|
self.backgroundColor = backgroundColor
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
content
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
backgroundColor ??
|
||||||
|
(isHighlighted ? Color.green.opacity(0.2) : Color(white: 0.12))
|
||||||
|
)
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
FirkaCard {
|
||||||
|
Text("Normal Card")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
FirkaCard(isHighlighted: true) {
|
||||||
|
Text("Highlighted Card")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
39
firka/ios/FirkaWatch Watch App/Components/GradeBadge.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GradeBadge: View {
|
||||||
|
let grade: Int
|
||||||
|
var size: CGFloat = 24
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch grade {
|
||||||
|
case 5: return .green
|
||||||
|
case 4: return .blue
|
||||||
|
case 3: return .yellow
|
||||||
|
case 2: return .orange
|
||||||
|
default: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
|
||||||
|
Text("\(grade)")
|
||||||
|
.font(.system(size: size * 0.5, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
GradeBadge(grade: 5)
|
||||||
|
GradeBadge(grade: 4)
|
||||||
|
GradeBadge(grade: 3)
|
||||||
|
GradeBadge(grade: 2)
|
||||||
|
GradeBadge(grade: 1)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
46
firka/ios/FirkaWatch Watch App/Components/GradeRow.swift
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GradeRow: View {
|
||||||
|
let grade: WidgetGrade
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
Text(grade.displayValue)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(grade.gradeColor)
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
if let topic = grade.topic {
|
||||||
|
Text(topic)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(grade.type.name)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
if let weight = grade.weightPercentage, weight != 100 {
|
||||||
|
Text("(\(weight)%)")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color(white: 0.15))
|
||||||
|
.cornerRadius(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
146
firka/ios/FirkaWatch Watch App/Components/LessonCard.swift
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LessonCard: View {
|
||||||
|
let lesson: WidgetLesson
|
||||||
|
let isActive: Bool
|
||||||
|
let colors: WidgetColors?
|
||||||
|
|
||||||
|
var backgroundColor: Color {
|
||||||
|
if let colors = colors {
|
||||||
|
return colors.cardColor
|
||||||
|
}
|
||||||
|
return Color(white: 0.15)
|
||||||
|
}
|
||||||
|
|
||||||
|
var textPrimaryColor: Color {
|
||||||
|
if let colors = colors {
|
||||||
|
return colors.textPrimaryColor
|
||||||
|
}
|
||||||
|
return .primary
|
||||||
|
}
|
||||||
|
|
||||||
|
var textSecondaryColor: Color {
|
||||||
|
if let colors = colors {
|
||||||
|
return colors.textSecondaryColor
|
||||||
|
}
|
||||||
|
return .secondary
|
||||||
|
}
|
||||||
|
|
||||||
|
var textTertiaryColor: Color {
|
||||||
|
if let colors = colors {
|
||||||
|
return colors.textTertiaryColor
|
||||||
|
}
|
||||||
|
return .secondary.opacity(0.7)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
if let number = lesson.lessonNumber {
|
||||||
|
Text("\(number)")
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(isActive ? .white : textPrimaryColor)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(isActive ? Color.green : Color.clear)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(lesson.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(lesson.isCancelled ? .red :
|
||||||
|
lesson.isSubstitution ? .orange : textPrimaryColor)
|
||||||
|
.strikethrough(lesson.isCancelled, color: .red)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(lesson.timeString)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(lesson.isCancelled ? .red.opacity(0.8) :
|
||||||
|
lesson.isSubstitution ? .orange.opacity(0.8) : textSecondaryColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let room = lesson.roomName {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "door.right.hand.closed")
|
||||||
|
.font(.caption2)
|
||||||
|
Text(room)
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.foregroundColor(lesson.isCancelled ? .red.opacity(0.7) :
|
||||||
|
lesson.isSubstitution ? .orange.opacity(0.7) : textSecondaryColor)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let teacher = lesson.teacher {
|
||||||
|
Text(teacher)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(lesson.isCancelled ? .red.opacity(0.7) :
|
||||||
|
lesson.isSubstitution ? .orange.opacity(0.7) : textTertiaryColor)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.cornerRadius(10)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(
|
||||||
|
isActive ? Color.green : Color.clear,
|
||||||
|
lineWidth: isActive ? 2 : 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
LessonCard(
|
||||||
|
lesson: WidgetLesson(
|
||||||
|
uid: "1",
|
||||||
|
date: "2026-02-01",
|
||||||
|
start: Date(),
|
||||||
|
end: Date().addingTimeInterval(3600),
|
||||||
|
name: "Matematika",
|
||||||
|
lessonNumber: 3,
|
||||||
|
teacher: "Nagy János",
|
||||||
|
substituteTeacher: nil,
|
||||||
|
subject: WidgetSubject(uid: "math", name: "Matematika", category: nil, sortIndex: 1, teacherName: "Nagy János"),
|
||||||
|
theme: nil,
|
||||||
|
roomName: "201",
|
||||||
|
isCancelled: false,
|
||||||
|
isSubstitution: false
|
||||||
|
),
|
||||||
|
isActive: true,
|
||||||
|
colors: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
LessonCard(
|
||||||
|
lesson: WidgetLesson(
|
||||||
|
uid: "2",
|
||||||
|
date: "2026-02-01",
|
||||||
|
start: Date().addingTimeInterval(7200),
|
||||||
|
end: Date().addingTimeInterval(10800),
|
||||||
|
name: "Angol",
|
||||||
|
lessonNumber: 4,
|
||||||
|
teacher: "Kovács Éva",
|
||||||
|
substituteTeacher: nil,
|
||||||
|
subject: WidgetSubject(uid: "eng", name: "Angol", category: nil, sortIndex: 2, teacherName: "Kovács Éva"),
|
||||||
|
theme: nil,
|
||||||
|
roomName: "105",
|
||||||
|
isCancelled: false,
|
||||||
|
isSubstitution: false
|
||||||
|
),
|
||||||
|
isActive: false,
|
||||||
|
colors: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
68
firka/ios/FirkaWatch Watch App/Components/ProgressBar.swift
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AverageProgressBar: View {
|
||||||
|
let average: Double
|
||||||
|
|
||||||
|
var progress: Double {
|
||||||
|
(average - 1) / 4
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch average {
|
||||||
|
case 4.5...: return .green
|
||||||
|
case 3.5..<4.5: return .blue
|
||||||
|
case 2.5..<3.5: return .yellow
|
||||||
|
case 1.5..<2.5: return .orange
|
||||||
|
default: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color(white: 0.3))
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: geo.size.width * progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("5.0 - Excellent")
|
||||||
|
.font(.caption)
|
||||||
|
AverageProgressBar(average: 5.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("4.2 - Good")
|
||||||
|
.font(.caption)
|
||||||
|
AverageProgressBar(average: 4.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("3.0 - Average")
|
||||||
|
.font(.caption)
|
||||||
|
AverageProgressBar(average: 3.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("2.0 - Below Average")
|
||||||
|
.font(.caption)
|
||||||
|
AverageProgressBar(average: 2.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("1.2 - Poor")
|
||||||
|
.font(.caption)
|
||||||
|
AverageProgressBar(average: 1.2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
43
firka/ios/FirkaWatch Watch App/Components/SubjectRow.swift
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SubjectRow: View {
|
||||||
|
let name: String
|
||||||
|
let average: Double?
|
||||||
|
let gradeCount: Int
|
||||||
|
|
||||||
|
var averageColor: Color {
|
||||||
|
guard let avg = average else { return .gray }
|
||||||
|
switch avg {
|
||||||
|
case 4.5...: return .green
|
||||||
|
case 3.5...: return .blue
|
||||||
|
case 2.5...: return .yellow
|
||||||
|
case 1.5...: return .orange
|
||||||
|
default: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
Text(name)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let avg = average {
|
||||||
|
Text(String(format: "%.2f", avg))
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundColor(averageColor)
|
||||||
|
} else {
|
||||||
|
Text("\(gradeCount)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color(white: 0.15))
|
||||||
|
.cornerRadius(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
189
firka/ios/FirkaWatch Watch App/ContentView.swift
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WatchConnectivity
|
||||||
|
internal import Combine
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
var dataStore = DataStore.shared
|
||||||
|
@State private var selectedTab = 0
|
||||||
|
@State private var isRequestingToken = false
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
|
private let staleCheckTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
|
private let autoRefreshThreshold: TimeInterval = 10 * 60
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if dataStore.isRecoveringToken {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(1.2)
|
||||||
|
Text("recovering_token".localized)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
else if dataStore.needsReauth && dataStore.hasToken {
|
||||||
|
ReauthRequiredView(onTokenReceived: {
|
||||||
|
dataStore.resetRecoveryState()
|
||||||
|
dataStore.checkTokenState()
|
||||||
|
Task {
|
||||||
|
await dataStore.refreshAllWithRecovery()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if !dataStore.hasToken && dataStore.data == nil {
|
||||||
|
if isRequestingToken {
|
||||||
|
ProgressView("connecting".localized)
|
||||||
|
} else {
|
||||||
|
PairingView(onRequestToken: requestToken)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mainContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
dataStore.reconcileSharedSessionState()
|
||||||
|
WatchL10n.shared.reconcileFromSharedState()
|
||||||
|
dataStore.checkTokenState()
|
||||||
|
dataStore.loadFromCache()
|
||||||
|
if dataStore.hasToken {
|
||||||
|
await dataStore.refreshAllWithRecovery()
|
||||||
|
} else {
|
||||||
|
requestToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: scenePhase) { oldPhase, newPhase in
|
||||||
|
if newPhase == .active && oldPhase != .active {
|
||||||
|
dataStore.reconcileSharedSessionState()
|
||||||
|
WatchL10n.shared.reconcileFromSharedState()
|
||||||
|
if shouldAutoRefresh {
|
||||||
|
print("[Watch] App came to foreground, data is stale (>10 min), refreshing...")
|
||||||
|
Task {
|
||||||
|
await dataStore.refreshAllWithRecovery()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("[Watch] App came to foreground, data is fresh (<10 min), skipping refresh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onReceive(staleCheckTimer) { _ in
|
||||||
|
guard scenePhase == .active else { return }
|
||||||
|
dataStore.reconcileSharedSessionState()
|
||||||
|
WatchL10n.shared.reconcileFromSharedState()
|
||||||
|
|
||||||
|
if !dataStore.hasToken {
|
||||||
|
dataStore.checkTokenState()
|
||||||
|
if dataStore.hasToken {
|
||||||
|
print("[Watch] Token appeared (iCloud Keychain sync?), refreshing...")
|
||||||
|
Task {
|
||||||
|
await dataStore.refreshAllWithRecovery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldAutoRefresh && !dataStore.isLoading {
|
||||||
|
print("[Watch] Data became stale (>10 min), auto-refreshing...")
|
||||||
|
Task {
|
||||||
|
await dataStore.refreshAllWithRecovery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shouldAutoRefresh: Bool {
|
||||||
|
guard dataStore.hasToken else { return false }
|
||||||
|
guard let lastUpdated = dataStore.lastUpdated else { return true }
|
||||||
|
let elapsed = Date().timeIntervalSince(lastUpdated)
|
||||||
|
return elapsed >= autoRefreshThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestToken() {
|
||||||
|
guard !isRequestingToken else { return }
|
||||||
|
guard WCSession.default.activationState == .activated else {
|
||||||
|
print("[Watch] Cannot request token: session not activated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard WCSession.default.isReachable else {
|
||||||
|
print("[Watch] Cannot request token: iPhone not reachable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[Watch] Requesting token from iPhone...")
|
||||||
|
isRequestingToken = true
|
||||||
|
WatchConnectivityManager.shared.requestTokenFromPhone()
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
|
||||||
|
self.isRequestingToken = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mainContent: some View {
|
||||||
|
TabView(selection: $selectedTab) {
|
||||||
|
HomeView(dataStore: dataStore)
|
||||||
|
.tag(0)
|
||||||
|
|
||||||
|
TimetableView(dataStore: dataStore)
|
||||||
|
.tag(1)
|
||||||
|
|
||||||
|
GradesView(dataStore: dataStore)
|
||||||
|
.tag(2)
|
||||||
|
|
||||||
|
NavigationStack {
|
||||||
|
SettingsView()
|
||||||
|
}
|
||||||
|
.tag(3)
|
||||||
|
}
|
||||||
|
.tabViewStyle(.verticalPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PairingView: View {
|
||||||
|
var onRequestToken: (() -> Void)?
|
||||||
|
|
||||||
|
private var isWatchSystemPaired: Bool {
|
||||||
|
guard WCSession.isSupported() else { return false }
|
||||||
|
return WCSession.default.isCompanionAppInstalled
|
||||||
|
}
|
||||||
|
|
||||||
|
private var titleKey: String {
|
||||||
|
isWatchSystemPaired ? "login_on_iphone" : "pair_with_iphone"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var descriptionKey: String {
|
||||||
|
isWatchSystemPaired ? "open_and_login_on_iphone" : "open_firka_on_iphone"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconName: String {
|
||||||
|
isWatchSystemPaired
|
||||||
|
? "person.crop.circle.badge.exclamationmark"
|
||||||
|
: "iphone.and.arrow.right.inward"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Image(systemName: iconName)
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
|
||||||
|
Text(titleKey.localized)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text(descriptionKey.localized)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
if isWatchSystemPaired {
|
||||||
|
Button("sync_button".localized) {
|
||||||
|
onRequestToken?()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.app.firka.firkaa</string>
|
||||||
|
</array>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)app.firka.shared</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
|
<string>$(TeamIdentifierPrefix)app.firka.firkaa</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
35
firka/ios/FirkaWatch Watch App/FirkaWatchApp.swift
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WatchKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct FirkaWatchApp: App {
|
||||||
|
@WKApplicationDelegateAdaptor(WatchAppDelegate.self) var delegate
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WatchAppDelegate: NSObject, WKApplicationDelegate {
|
||||||
|
func applicationDidFinishLaunching() {
|
||||||
|
print("[Watch] applicationDidFinishLaunching called")
|
||||||
|
WatchConnectivityManager.shared.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
|
||||||
|
for task in backgroundTasks {
|
||||||
|
switch task {
|
||||||
|
case let refreshTask as WKApplicationRefreshBackgroundTask:
|
||||||
|
Task {
|
||||||
|
await BackgroundRefreshManager.shared.handleBackgroundRefresh()
|
||||||
|
refreshTask.setTaskCompletedWithSnapshot(false)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
task.setTaskCompletedWithSnapshot(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
464
firka/ios/FirkaWatch Watch App/Localization/WatchL10n.swift
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
enum WatchLanguage: String, CaseIterable, Codable {
|
||||||
|
case hungarian = "hu"
|
||||||
|
case english = "en"
|
||||||
|
case german = "de"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .hungarian: return "Magyar"
|
||||||
|
case .english: return "English"
|
||||||
|
case .german: return "Deutsch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var flag: String {
|
||||||
|
switch self {
|
||||||
|
case .hungarian: return "🇭🇺"
|
||||||
|
case .english: return "🇬🇧"
|
||||||
|
case .german: return "🇩🇪"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class WatchL10n {
|
||||||
|
static let shared = WatchL10n()
|
||||||
|
|
||||||
|
private let languageKey = "watch_language"
|
||||||
|
private let syncWithiPhoneKey = "watch_sync_language_with_iphone"
|
||||||
|
private let lastAppliedSharedLanguageVersionKey = "watch_last_applied_shared_language_version"
|
||||||
|
private static let appGroupID = "group.app.firka.firkaa"
|
||||||
|
private var appGroupDefaults: UserDefaults? {
|
||||||
|
UserDefaults(suiteName: Self.appGroupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentLanguage: WatchLanguage {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(currentLanguage.rawValue, forKey: languageKey)
|
||||||
|
appGroupDefaults?.set(currentLanguage.rawValue, forKey: languageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var syncWithiPhone: Bool {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(syncWithiPhone, forKey: syncWithiPhoneKey)
|
||||||
|
appGroupDefaults?.set(syncWithiPhone, forKey: syncWithiPhoneKey)
|
||||||
|
if syncWithiPhone {
|
||||||
|
refreshFromiPhoneAndSharedState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var strings: [String: String] = [:]
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let savedLanguage = UserDefaults.standard.string(forKey: languageKey) ?? "hu"
|
||||||
|
self.currentLanguage = WatchLanguage(rawValue: savedLanguage) ?? .hungarian
|
||||||
|
if let storedSyncPref = UserDefaults.standard.object(forKey: syncWithiPhoneKey) as? Bool {
|
||||||
|
self.syncWithiPhone = storedSyncPref
|
||||||
|
} else {
|
||||||
|
self.syncWithiPhone = true
|
||||||
|
UserDefaults.standard.set(true, forKey: syncWithiPhoneKey)
|
||||||
|
appGroupDefaults?.set(true, forKey: syncWithiPhoneKey)
|
||||||
|
}
|
||||||
|
appGroupDefaults?.set(currentLanguage.rawValue, forKey: languageKey)
|
||||||
|
loadStrings()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadStrings() {
|
||||||
|
strings = Self.stringsForLanguage(currentLanguage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setLanguage(_ language: WatchLanguage) {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
currentLanguage = language
|
||||||
|
loadStrings()
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async { [self] in
|
||||||
|
currentLanguage = language
|
||||||
|
loadStrings()
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFromiPhone(languageCode: String, sharedStateVersion: Int64? = nil) {
|
||||||
|
guard syncWithiPhone else { return }
|
||||||
|
let lastAppliedVersion = lastAppliedSharedLanguageVersion()
|
||||||
|
if let sharedStateVersion,
|
||||||
|
sharedStateVersion > 0,
|
||||||
|
sharedStateVersion < lastAppliedVersion {
|
||||||
|
print("[WatchL10n] Ignoring stale WC language update (version: \(sharedStateVersion), lastApplied: \(lastAppliedVersion))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let language = WatchLanguage(rawValue: languageCode) {
|
||||||
|
if language != currentLanguage {
|
||||||
|
setLanguage(language)
|
||||||
|
}
|
||||||
|
if let sharedStateVersion, sharedStateVersion > 0 {
|
||||||
|
setLastAppliedSharedLanguageVersion(max(lastAppliedVersion, sharedStateVersion))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseInt64(_ value: Any?) -> Int64? {
|
||||||
|
if let value = value as? Int64 { return value }
|
||||||
|
if let value = value as? Int { return Int64(value) }
|
||||||
|
if let value = value as? Double { return Int64(value) }
|
||||||
|
if let value = value as? String, let parsed = Int64(value) { return parsed }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lastAppliedSharedLanguageVersion() -> Int64 {
|
||||||
|
parseInt64(UserDefaults.standard.object(forKey: lastAppliedSharedLanguageVersionKey)) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setLastAppliedSharedLanguageVersion(_ value: Int64) {
|
||||||
|
UserDefaults.standard.set(value, forKey: lastAppliedSharedLanguageVersionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetLanguageVersionTracking() {
|
||||||
|
setLastAppliedSharedLanguageVersion(0)
|
||||||
|
print("[WatchL10n] Language version tracking reset for account switch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconcileFromSharedState() {
|
||||||
|
guard syncWithiPhone else { return }
|
||||||
|
guard let sharedState = SharedLanguageStateManager.shared.loadState() else { return }
|
||||||
|
let lastAppliedVersion = lastAppliedSharedLanguageVersion()
|
||||||
|
guard sharedState.stateVersion > lastAppliedVersion else { return }
|
||||||
|
|
||||||
|
if let language = WatchLanguage(rawValue: sharedState.languageCode) {
|
||||||
|
if language != currentLanguage {
|
||||||
|
setLanguage(language)
|
||||||
|
}
|
||||||
|
setLastAppliedSharedLanguageVersion(sharedState.stateVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshFromiPhoneAndSharedState() {
|
||||||
|
guard syncWithiPhone else { return }
|
||||||
|
requestLanguageFromiPhone()
|
||||||
|
reconcileFromSharedState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestLanguageFromiPhone() {
|
||||||
|
WatchConnectivityManager.shared.requestLanguageFromPhone()
|
||||||
|
}
|
||||||
|
|
||||||
|
func string(_ key: String) -> String {
|
||||||
|
return strings[key] ?? key
|
||||||
|
}
|
||||||
|
|
||||||
|
func string(_ key: String, _ args: CVarArg...) -> String {
|
||||||
|
let format = strings[key] ?? key
|
||||||
|
return String(format: format, arguments: args)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stringsForLanguage(_ language: WatchLanguage) -> [String: String] {
|
||||||
|
switch language {
|
||||||
|
case .hungarian:
|
||||||
|
return hungarianStrings
|
||||||
|
case .english:
|
||||||
|
return englishStrings
|
||||||
|
case .german:
|
||||||
|
return germanStrings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let hungarianStrings: [String: String] = [
|
||||||
|
// Home View
|
||||||
|
"current_lesson": "Jelenlegi óra",
|
||||||
|
"next": "Következő",
|
||||||
|
"break": "Szünet",
|
||||||
|
"next_lesson": "Következő: %@",
|
||||||
|
"first_lesson": "Első órád",
|
||||||
|
"today_lessons_count": "Ma %d órád van",
|
||||||
|
"no_more_lessons": "Ma nincs több órád",
|
||||||
|
"pair_with_iphone": "Párosítsd az iPhone-oddal",
|
||||||
|
"open_firka_on_iphone": "Nyisd meg a Firka appot az iPhone-odon",
|
||||||
|
"login_on_iphone": "Jelentkezz be iPhone-on",
|
||||||
|
"open_and_login_on_iphone": "Nyisd meg a Firka appot iPhone-on, és lépj be egy fiókba",
|
||||||
|
"updated": "Frissítve: %@",
|
||||||
|
"minutes": "perc",
|
||||||
|
"time_now": "most",
|
||||||
|
"time_hours_minutes": "%d ó %d p",
|
||||||
|
"time_hours": "%d óra",
|
||||||
|
"time_minutes_only": "%d perc",
|
||||||
|
"time_since_minutes_one": "1 perce",
|
||||||
|
"time_since_minutes_many": "%d perce",
|
||||||
|
"time_since_hours_one": "1 órája",
|
||||||
|
"time_since_hours_many": "%d órája",
|
||||||
|
"time_since_days_one": "1 napja",
|
||||||
|
"time_since_days_many": "%d napja",
|
||||||
|
|
||||||
|
// Timetable View
|
||||||
|
"free_day": "Szabad nap",
|
||||||
|
"lesson_number": "%d. óra",
|
||||||
|
"day_mon": "H",
|
||||||
|
"day_tue": "K",
|
||||||
|
"day_wed": "Sz",
|
||||||
|
"day_thu": "Cs",
|
||||||
|
"day_fri": "P",
|
||||||
|
|
||||||
|
// Grades View
|
||||||
|
"grades_count": "%d jegy",
|
||||||
|
"total_average": "Teljes átlag",
|
||||||
|
"average": "Átlag:",
|
||||||
|
"no_data": "Nincs adat",
|
||||||
|
"no_grades": "Nincsenek jegyek",
|
||||||
|
|
||||||
|
// Lesson Detail
|
||||||
|
"lesson_details": "Óra részletei",
|
||||||
|
"cancelled": "Elmarad",
|
||||||
|
"substitution": "Helyettesítés",
|
||||||
|
"teacher": "Tanár",
|
||||||
|
"room": "Terem",
|
||||||
|
"topic": "Téma",
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
"settings": "Beállítások",
|
||||||
|
"refresh_interval": "Frissítési időköz",
|
||||||
|
"auto": "Automatikus",
|
||||||
|
"15_minutes": "15 perc",
|
||||||
|
"30_minutes": "30 perc",
|
||||||
|
"1_hour": "1 óra",
|
||||||
|
"version": "Verzió",
|
||||||
|
"language": "Nyelv",
|
||||||
|
"sync_with_iphone": "iPhone nyelvével",
|
||||||
|
"clear_cache": "Cache törlése",
|
||||||
|
"logout": "Kijelentkezés",
|
||||||
|
|
||||||
|
// Refresh
|
||||||
|
"refresh": "Frissítés",
|
||||||
|
"refreshing": "Frissítés...",
|
||||||
|
"refresh_success": "Sikeres!",
|
||||||
|
"refresh_failed": "Sikertelen",
|
||||||
|
"error_api": "Kréta API hiba",
|
||||||
|
"error_network": "Hálózati hiba",
|
||||||
|
|
||||||
|
// Date labels
|
||||||
|
"tomorrow_first_lesson": "Holnap első órád",
|
||||||
|
"day_first_lesson": "%@ első órád",
|
||||||
|
"next_school_day": "Következő iskolai nap",
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
"home": "Kezdőlap",
|
||||||
|
"timetable": "Órarend",
|
||||||
|
"grades": "Jegyek",
|
||||||
|
|
||||||
|
// Reauth
|
||||||
|
"reauth_required": "Újrabelépés szükséges",
|
||||||
|
"reauth_description": "A munkamenet lejárt. Lépj be újra az iPhone appban.",
|
||||||
|
"sync_button": "Szinkronizálás",
|
||||||
|
"syncing": "Szinkronizálás...",
|
||||||
|
"sync_success": "Sikeres!",
|
||||||
|
"sync_failed": "Sikertelen",
|
||||||
|
"phone_not_reachable": "iPhone nem elérhető",
|
||||||
|
"connecting": "Kapcsolódás...",
|
||||||
|
"recovering_token": "Token helyreállítása...",
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let englishStrings: [String: String] = [
|
||||||
|
// Home View
|
||||||
|
"current_lesson": "Current Lesson",
|
||||||
|
"next": "Next",
|
||||||
|
"break": "Break",
|
||||||
|
"next_lesson": "Next: %@",
|
||||||
|
"first_lesson": "First Lesson",
|
||||||
|
"today_lessons_count": "You have %d lessons today",
|
||||||
|
"no_more_lessons": "No more lessons today",
|
||||||
|
"pair_with_iphone": "Pair with iPhone",
|
||||||
|
"open_firka_on_iphone": "Open Firka app on your iPhone",
|
||||||
|
"login_on_iphone": "Sign in on iPhone",
|
||||||
|
"open_and_login_on_iphone": "Open Firka on your iPhone and sign in to an account",
|
||||||
|
"updated": "Updated: %@",
|
||||||
|
"minutes": "min",
|
||||||
|
"time_now": "now",
|
||||||
|
"time_hours_minutes": "%dh %dm",
|
||||||
|
"time_hours": "%d hours",
|
||||||
|
"time_minutes_only": "%d min",
|
||||||
|
"time_since_minutes_one": "1 min ago",
|
||||||
|
"time_since_minutes_many": "%d mins ago",
|
||||||
|
"time_since_hours_one": "1 hour ago",
|
||||||
|
"time_since_hours_many": "%d hours ago",
|
||||||
|
"time_since_days_one": "1 day ago",
|
||||||
|
"time_since_days_many": "%d days ago",
|
||||||
|
|
||||||
|
// Timetable View
|
||||||
|
"free_day": "Free Day",
|
||||||
|
"lesson_number": "Lesson %d",
|
||||||
|
"day_mon": "Mon",
|
||||||
|
"day_tue": "Tue",
|
||||||
|
"day_wed": "Wed",
|
||||||
|
"day_thu": "Thu",
|
||||||
|
"day_fri": "Fri",
|
||||||
|
|
||||||
|
// Grades View
|
||||||
|
"grades_count": "%d grades",
|
||||||
|
"total_average": "Total Average",
|
||||||
|
"average": "Average:",
|
||||||
|
"no_data": "No data",
|
||||||
|
"no_grades": "No grades",
|
||||||
|
|
||||||
|
// Lesson Detail
|
||||||
|
"lesson_details": "Lesson Details",
|
||||||
|
"cancelled": "Cancelled",
|
||||||
|
"substitution": "Substitution",
|
||||||
|
"teacher": "Teacher",
|
||||||
|
"room": "Room",
|
||||||
|
"topic": "Topic",
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
"settings": "Settings",
|
||||||
|
"refresh_interval": "Refresh Interval",
|
||||||
|
"auto": "Auto",
|
||||||
|
"15_minutes": "15 minutes",
|
||||||
|
"30_minutes": "30 minutes",
|
||||||
|
"1_hour": "1 hour",
|
||||||
|
"version": "Version",
|
||||||
|
"language": "Language",
|
||||||
|
"sync_with_iphone": "Sync with iPhone",
|
||||||
|
"clear_cache": "Clear Cache",
|
||||||
|
"logout": "Log Out",
|
||||||
|
|
||||||
|
// Refresh
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"refreshing": "Refreshing...",
|
||||||
|
"refresh_success": "Success!",
|
||||||
|
"refresh_failed": "Failed",
|
||||||
|
"error_api": "Kréta API Error",
|
||||||
|
"error_network": "Network Error",
|
||||||
|
|
||||||
|
// Date labels
|
||||||
|
"tomorrow_first_lesson": "Tomorrow's first lesson",
|
||||||
|
"day_first_lesson": "%@'s first lesson",
|
||||||
|
"next_school_day": "Next school day",
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
"home": "Home",
|
||||||
|
"timetable": "Timetable",
|
||||||
|
"grades": "Grades",
|
||||||
|
|
||||||
|
// Reauth
|
||||||
|
"reauth_required": "Re-login Required",
|
||||||
|
"reauth_description": "Your session has expired. Please log in again on your iPhone.",
|
||||||
|
"sync_button": "Sync",
|
||||||
|
"syncing": "Syncing...",
|
||||||
|
"sync_success": "Success!",
|
||||||
|
"sync_failed": "Failed",
|
||||||
|
"phone_not_reachable": "iPhone not reachable",
|
||||||
|
"connecting": "Connecting...",
|
||||||
|
"recovering_token": "Recovering session...",
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let germanStrings: [String: String] = [
|
||||||
|
// Home View
|
||||||
|
"current_lesson": "Aktuelle Stunde",
|
||||||
|
"next": "Nächste",
|
||||||
|
"break": "Pause",
|
||||||
|
"next_lesson": "Nächste: %@",
|
||||||
|
"first_lesson": "Erste Stunde",
|
||||||
|
"today_lessons_count": "Du hast heute %d Stunden",
|
||||||
|
"no_more_lessons": "Keine Stunden mehr heute",
|
||||||
|
"pair_with_iphone": "Mit iPhone koppeln",
|
||||||
|
"open_firka_on_iphone": "Öffne Firka auf deinem iPhone",
|
||||||
|
"login_on_iphone": "Auf iPhone anmelden",
|
||||||
|
"open_and_login_on_iphone": "Öffne Firka auf deinem iPhone und melde dich mit einem Konto an",
|
||||||
|
"updated": "Aktualisiert: %@",
|
||||||
|
"minutes": "Min",
|
||||||
|
"time_now": "jetzt",
|
||||||
|
"time_hours_minutes": "%d Std %d Min",
|
||||||
|
"time_hours": "%d Stunden",
|
||||||
|
"time_minutes_only": "%d Min",
|
||||||
|
"time_since_minutes_one": "vor 1 Min",
|
||||||
|
"time_since_minutes_many": "vor %d Min",
|
||||||
|
"time_since_hours_one": "vor 1 Std",
|
||||||
|
"time_since_hours_many": "vor %d Std",
|
||||||
|
"time_since_days_one": "vor 1 Tag",
|
||||||
|
"time_since_days_many": "vor %d Tagen",
|
||||||
|
|
||||||
|
// Timetable View
|
||||||
|
"free_day": "Freier Tag",
|
||||||
|
"lesson_number": "%d. Stunde",
|
||||||
|
"day_mon": "Mo",
|
||||||
|
"day_tue": "Di",
|
||||||
|
"day_wed": "Mi",
|
||||||
|
"day_thu": "Do",
|
||||||
|
"day_fri": "Fr",
|
||||||
|
|
||||||
|
// Grades View
|
||||||
|
"grades_count": "%d Noten",
|
||||||
|
"total_average": "Gesamtdurchschnitt",
|
||||||
|
"average": "Durchschnitt:",
|
||||||
|
"no_data": "Keine Daten",
|
||||||
|
"no_grades": "Keine Noten",
|
||||||
|
|
||||||
|
// Lesson Detail
|
||||||
|
"lesson_details": "Stundendetails",
|
||||||
|
"cancelled": "Entfällt",
|
||||||
|
"substitution": "Vertretung",
|
||||||
|
"teacher": "Lehrer",
|
||||||
|
"room": "Raum",
|
||||||
|
"topic": "Thema",
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
"settings": "Einstellungen",
|
||||||
|
"refresh_interval": "Aktualisierungsintervall",
|
||||||
|
"auto": "Automatisch",
|
||||||
|
"15_minutes": "15 Minuten",
|
||||||
|
"30_minutes": "30 Minuten",
|
||||||
|
"1_hour": "1 Stunde",
|
||||||
|
"version": "Version",
|
||||||
|
"language": "Sprache",
|
||||||
|
"sync_with_iphone": "Mit iPhone synchronisieren",
|
||||||
|
"clear_cache": "Cache löschen",
|
||||||
|
"logout": "Abmelden",
|
||||||
|
|
||||||
|
// Refresh
|
||||||
|
"refresh": "Aktualisieren",
|
||||||
|
"refreshing": "Wird aktualisiert...",
|
||||||
|
"refresh_success": "Erfolgreich!",
|
||||||
|
"refresh_failed": "Fehlgeschlagen",
|
||||||
|
"error_api": "Kréta API Fehler",
|
||||||
|
"error_network": "Netzwerkfehler",
|
||||||
|
|
||||||
|
// Date labels
|
||||||
|
"tomorrow_first_lesson": "Morgen erste Stunde",
|
||||||
|
"day_first_lesson": "%@ erste Stunde",
|
||||||
|
"next_school_day": "Nächster Schultag",
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
"home": "Startseite",
|
||||||
|
"timetable": "Stundenplan",
|
||||||
|
"grades": "Noten",
|
||||||
|
|
||||||
|
// Reauth
|
||||||
|
"reauth_required": "Erneute Anmeldung erforderlich",
|
||||||
|
"reauth_description": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut auf dem iPhone an.",
|
||||||
|
"sync_button": "Synchronisieren",
|
||||||
|
"syncing": "Synchronisierung...",
|
||||||
|
"sync_success": "Erfolgreich!",
|
||||||
|
"sync_failed": "Fehlgeschlagen",
|
||||||
|
"phone_not_reachable": "iPhone nicht erreichbar",
|
||||||
|
"connecting": "Verbindung...",
|
||||||
|
"recovering_token": "Sitzung wiederherstellen...",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
var localized: String {
|
||||||
|
WatchL10n.shared.string(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func localized(_ args: CVarArg...) -> String {
|
||||||
|
let format = WatchL10n.shared.string(self)
|
||||||
|
return String(format: format, arguments: args)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import Foundation
|
||||||
|
import WatchKit
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
class BackgroundRefreshManager {
|
||||||
|
static let shared = BackgroundRefreshManager()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func scheduleNextRefresh() {
|
||||||
|
let interval = calculateOptimalRefreshInterval()
|
||||||
|
|
||||||
|
let preferredDate = Date().addingTimeInterval(interval)
|
||||||
|
WKApplication.shared().scheduleBackgroundRefresh(
|
||||||
|
withPreferredDate: preferredDate,
|
||||||
|
userInfo: nil
|
||||||
|
) { error in
|
||||||
|
if let error = error {
|
||||||
|
print("[BackgroundRefresh] Schedule error: \(error)")
|
||||||
|
} else {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "HH:mm"
|
||||||
|
print("[BackgroundRefresh] Next refresh scheduled at: \(formatter.string(from: preferredDate)) (\(Int(interval/60)) min)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateOptimalRefreshInterval() -> TimeInterval {
|
||||||
|
let userRefreshMinutes = UserDefaults.standard.integer(forKey: "refreshInterval")
|
||||||
|
let now = Date()
|
||||||
|
let calendar = Calendar.current
|
||||||
|
|
||||||
|
let todayLessons = getTodayLessons()
|
||||||
|
|
||||||
|
guard !todayLessons.isEmpty else {
|
||||||
|
return getDefaultInterval(userSetting: userRefreshMinutes, now: now, calendar: calendar)
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortedLessons = todayLessons.sorted { $0.start < $1.start }
|
||||||
|
|
||||||
|
guard let firstLesson = sortedLessons.first,
|
||||||
|
let lastLesson = sortedLessons.last else {
|
||||||
|
return getDefaultInterval(userSetting: userRefreshMinutes, now: now, calendar: calendar)
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstStart = firstLesson.start
|
||||||
|
let lastEnd = lastLesson.end
|
||||||
|
|
||||||
|
let schoolStartBuffer = firstStart.addingTimeInterval(-30 * 60)
|
||||||
|
|
||||||
|
if now < schoolStartBuffer {
|
||||||
|
let intervalUntilWakeUp = schoolStartBuffer.timeIntervalSince(now)
|
||||||
|
let interval = max(intervalUntilWakeUp, 15 * 60)
|
||||||
|
print("[BackgroundRefresh] Before school - next refresh in \(Int(interval/60)) min (30 min before first lesson)")
|
||||||
|
return min(interval, 60 * 60) // Max 1 hour
|
||||||
|
}
|
||||||
|
|
||||||
|
if now >= schoolStartBuffer && now <= lastEnd {
|
||||||
|
let interval = TimeInterval((userRefreshMinutes > 0 ? userRefreshMinutes : 15) * 60)
|
||||||
|
print("[BackgroundRefresh] During school - using \(Int(interval/60)) min interval")
|
||||||
|
return interval
|
||||||
|
}
|
||||||
|
|
||||||
|
let tomorrowLessons = getTomorrowLessons()
|
||||||
|
if !tomorrowLessons.isEmpty,
|
||||||
|
let tomorrowFirst = tomorrowLessons.sorted(by: { $0.start < $1.start }).first {
|
||||||
|
|
||||||
|
let tomorrowStartBuffer = tomorrowFirst.start.addingTimeInterval(-30 * 60)
|
||||||
|
let timeUntilTomorrowWakeUp = tomorrowStartBuffer.timeIntervalSince(now)
|
||||||
|
|
||||||
|
if timeUntilTomorrowWakeUp > 2 * 60 * 60 {
|
||||||
|
print("[BackgroundRefresh] After school - 1 hour interval (tomorrow's first lesson in \(Int(timeUntilTomorrowWakeUp/60)) min)")
|
||||||
|
return 60 * 60
|
||||||
|
} else {
|
||||||
|
print("[BackgroundRefresh] After school, tomorrow soon - 30 min interval")
|
||||||
|
return 30 * 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[BackgroundRefresh] After school, no tomorrow lessons - 1 hour interval")
|
||||||
|
return 60 * 60
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getDefaultInterval(userSetting: Int, now: Date, calendar: Calendar) -> TimeInterval {
|
||||||
|
if userSetting > 0 {
|
||||||
|
print("[BackgroundRefresh] No timetable - using user setting: \(userSetting) min")
|
||||||
|
return TimeInterval(userSetting * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
let hour = calendar.component(.hour, from: now)
|
||||||
|
let weekday = calendar.component(.weekday, from: now)
|
||||||
|
let isWeekday = weekday >= 2 && weekday <= 6
|
||||||
|
|
||||||
|
if isWeekday && hour >= 6 && hour <= 16 {
|
||||||
|
print("[BackgroundRefresh] No timetable - weekday school hours: 15 min")
|
||||||
|
return 15 * 60
|
||||||
|
} else {
|
||||||
|
print("[BackgroundRefresh] No timetable - off hours: 1 hour")
|
||||||
|
return 60 * 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getTodayLessons() -> [WidgetLesson] {
|
||||||
|
guard let data = DataStore.shared.data else { return [] }
|
||||||
|
return data.timetable.today
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getTomorrowLessons() -> [WidgetLesson] {
|
||||||
|
guard let data = DataStore.shared.data else { return [] }
|
||||||
|
return data.timetable.tomorrow
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleBackgroundRefresh() async {
|
||||||
|
await DataStore.shared.refreshAllWithRecovery()
|
||||||
|
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
|
||||||
|
scheduleNextRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
551
firka/ios/FirkaWatch Watch App/Services/DataStore.swift
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
// MARK: - Cache Wrapper
|
||||||
|
|
||||||
|
struct CachedWatchData: Codable {
|
||||||
|
let widgetData: WidgetData
|
||||||
|
let lastUpdated: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DataStore
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class DataStore {
|
||||||
|
static let shared = DataStore()
|
||||||
|
|
||||||
|
var data: WidgetData?
|
||||||
|
var lastUpdated: Date?
|
||||||
|
var isLoading: Bool = false
|
||||||
|
var error: String?
|
||||||
|
|
||||||
|
var isRecoveringToken: Bool = false
|
||||||
|
|
||||||
|
private(set) var recoveryAttempted: Bool = false
|
||||||
|
|
||||||
|
private(set) var hasToken: Bool = false
|
||||||
|
|
||||||
|
var needsReauth: Bool {
|
||||||
|
(error == "token_expired" || error == "no_token") && recoveryAttempted && !isRecoveringToken
|
||||||
|
}
|
||||||
|
|
||||||
|
private let appGroupID = "group.app.firka.firkaa"
|
||||||
|
private let cacheFileName = "watch_data.json"
|
||||||
|
private let lastHandledSessionStateVersionKey = "firka.watch.last_handled_session_state_version"
|
||||||
|
private let lastHandledSessionActiveStudentIdNormKey = "firka.watch.last_handled_session_active_student_id_norm"
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
checkTokenState()
|
||||||
|
loadFromCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var hasValidToken: Bool {
|
||||||
|
TokenManager.shared.loadToken() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkTokenState() {
|
||||||
|
hasToken = TokenManager.shared.loadToken() != nil
|
||||||
|
print("[Watch] Token state updated: hasToken = \(hasToken)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseInt64(_ value: Any?) -> Int64? {
|
||||||
|
if let value = value as? Int64 { return value }
|
||||||
|
if let value = value as? Int { return Int64(value) }
|
||||||
|
if let value = value as? Double { return Int64(value) }
|
||||||
|
if let value = value as? String, let parsed = Int64(value) { return parsed }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lastHandledSessionStateVersion() -> Int64 {
|
||||||
|
parseInt64(UserDefaults.standard.object(forKey: lastHandledSessionStateVersionKey)) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setLastHandledSessionStateVersion(_ value: Int64) {
|
||||||
|
UserDefaults.standard.set(value, forKey: lastHandledSessionStateVersionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lastHandledSessionActiveStudentIdNorm() -> Int64? {
|
||||||
|
parseInt64(UserDefaults.standard.object(forKey: lastHandledSessionActiveStudentIdNormKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setLastHandledSessionActiveStudentIdNorm(_ value: Int64?) {
|
||||||
|
if let value {
|
||||||
|
UserDefaults.standard.set(value, forKey: lastHandledSessionActiveStudentIdNormKey)
|
||||||
|
} else {
|
||||||
|
UserDefaults.standard.removeObject(forKey: lastHandledSessionActiveStudentIdNormKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconcileSharedSessionState() {
|
||||||
|
guard let state = SharedSessionStateManager.shared.loadState() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastVersion = lastHandledSessionStateVersion()
|
||||||
|
guard state.stateVersion > lastVersion else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.hasAnyAccount {
|
||||||
|
print("[Watch] Shared session state: no active iPhone account, clearing watch state")
|
||||||
|
clearAll()
|
||||||
|
resetRecoveryState()
|
||||||
|
setLastHandledSessionStateVersion(state.stateVersion)
|
||||||
|
setLastHandledSessionActiveStudentIdNorm(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let activeStudentIdNorm = state.activeStudentIdNorm {
|
||||||
|
let lastHandledActiveStudentIdNorm = lastHandledSessionActiveStudentIdNorm()
|
||||||
|
if lastHandledActiveStudentIdNorm != activeStudentIdNorm {
|
||||||
|
print("[Watch] Shared session switched active account to \(activeStudentIdNorm), clearing stale cache")
|
||||||
|
clearCache()
|
||||||
|
data = nil
|
||||||
|
lastUpdated = nil
|
||||||
|
error = nil
|
||||||
|
recoveryAttempted = false
|
||||||
|
WatchL10n.shared.resetLanguageVersionTracking()
|
||||||
|
}
|
||||||
|
setLastHandledSessionActiveStudentIdNorm(activeStudentIdNorm)
|
||||||
|
} else {
|
||||||
|
setLastHandledSessionActiveStudentIdNorm(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastHandledSessionStateVersion(state.stateVersion)
|
||||||
|
checkTokenState()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cache Loading
|
||||||
|
|
||||||
|
func loadFromCache() {
|
||||||
|
if let widgetData = WidgetData.load() {
|
||||||
|
self.data = widgetData
|
||||||
|
self.lastUpdated = widgetData.lastUpdated
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let cachedData = loadWatchCache() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.data = cachedData.widgetData
|
||||||
|
self.lastUpdated = cachedData.lastUpdated
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadWatchCache() -> CachedWatchData? {
|
||||||
|
guard let containerURL = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: appGroupID
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileURL = containerURL.appendingPathComponent(cacheFileName)
|
||||||
|
|
||||||
|
guard let cacheData = try? Data(contentsOf: fileURL) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
|
||||||
|
return try? decoder.decode(CachedWatchData.self, from: cacheData)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveToCache(_ data: WidgetData) {
|
||||||
|
guard let containerURL = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: appGroupID
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileURL = containerURL.appendingPathComponent(cacheFileName)
|
||||||
|
let cached = CachedWatchData(widgetData: data, lastUpdated: Date())
|
||||||
|
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
|
||||||
|
do {
|
||||||
|
let encodedData = try encoder.encode(cached)
|
||||||
|
try encodedData.write(to: fileURL)
|
||||||
|
} catch {
|
||||||
|
self.error = "Failed to save cache"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cache Management
|
||||||
|
|
||||||
|
func clearCache() {
|
||||||
|
guard let containerURL = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: appGroupID
|
||||||
|
) else { return }
|
||||||
|
|
||||||
|
let fileURL = containerURL.appendingPathComponent(cacheFileName)
|
||||||
|
try? FileManager.default.removeItem(at: fileURL)
|
||||||
|
|
||||||
|
data = nil
|
||||||
|
lastUpdated = nil
|
||||||
|
|
||||||
|
print("[Watch] Cache cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAll() {
|
||||||
|
clearCache()
|
||||||
|
error = nil
|
||||||
|
isLoading = false
|
||||||
|
checkTokenState()
|
||||||
|
|
||||||
|
print("[Watch] All data cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearError() {
|
||||||
|
error = nil
|
||||||
|
print("[Watch] Error cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setReauthRequired() {
|
||||||
|
error = "token_expired"
|
||||||
|
print("[Watch] Reauth required state set")
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetRecoveryState() {
|
||||||
|
recoveryAttempted = false
|
||||||
|
error = nil
|
||||||
|
print("[Watch] Recovery state reset")
|
||||||
|
}
|
||||||
|
|
||||||
|
func attemptTokenRecovery() async -> Bool {
|
||||||
|
guard !isRecoveringToken else {
|
||||||
|
print("[Watch] Token recovery already in progress")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
isRecoveringToken = true
|
||||||
|
recoveryAttempted = false
|
||||||
|
error = nil
|
||||||
|
print("[Watch] Starting token recovery via central method...")
|
||||||
|
|
||||||
|
defer {
|
||||||
|
isRecoveringToken = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if let token = TokenManager.shared.loadToken(), !TokenManager.shared.isTokenExpired() {
|
||||||
|
print("[Watch] Recovery: Token is already valid")
|
||||||
|
checkTokenState()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if let _ = await TokenManager.shared.recoverToken() {
|
||||||
|
print("[Watch] Recovery: Central recovery succeeded")
|
||||||
|
checkTokenState()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[Watch] Recovery: All attempts failed")
|
||||||
|
recoveryAttempted = true
|
||||||
|
self.error = "token_expired"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshComplications() {
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
print("[Watch] Complications refreshed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Proactive Token Refresh
|
||||||
|
|
||||||
|
func refreshTokenProactively() async {
|
||||||
|
guard hasValidToken else { return }
|
||||||
|
await TokenManager.shared.refreshTokenProactively()
|
||||||
|
checkTokenState()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Refresh
|
||||||
|
|
||||||
|
func refreshAll() async {
|
||||||
|
guard !isLoading else {
|
||||||
|
print("[Watch] DataStore.refreshAll() already in progress, skipping duplicate call")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[Watch] DataStore.refreshAll() called")
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
await TokenManager.shared.refreshTokenProactively()
|
||||||
|
|
||||||
|
guard hasValidToken else {
|
||||||
|
print("[Watch] No valid token, setting error = no_token")
|
||||||
|
error = "no_token"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (startOfWeek, endOfWeek) = getCurrentWeekDateRange()
|
||||||
|
|
||||||
|
async let timetableTask = KretaAPIClient.shared.fetchTimetable(
|
||||||
|
from: startOfWeek,
|
||||||
|
to: endOfWeek
|
||||||
|
)
|
||||||
|
async let gradesTask = KretaAPIClient.shared.fetchGrades()
|
||||||
|
|
||||||
|
let (lessons, grades) = try await (timetableTask, gradesTask)
|
||||||
|
|
||||||
|
let timetableData = buildTimetableData(from: lessons)
|
||||||
|
let averagesData = buildAveragesData(from: grades)
|
||||||
|
|
||||||
|
let widgetData = WidgetData(
|
||||||
|
lastUpdated: Date(),
|
||||||
|
locale: Locale.current.language.languageCode?.identifier ?? "hu",
|
||||||
|
theme: "dark",
|
||||||
|
timetable: timetableData,
|
||||||
|
grades: grades,
|
||||||
|
averages: averagesData
|
||||||
|
)
|
||||||
|
|
||||||
|
self.data = widgetData
|
||||||
|
self.lastUpdated = Date()
|
||||||
|
|
||||||
|
saveToCache(widgetData)
|
||||||
|
|
||||||
|
refreshComplications()
|
||||||
|
|
||||||
|
print("[Watch] refreshAll() completed successfully")
|
||||||
|
|
||||||
|
} catch let error as APIError {
|
||||||
|
handleAPIError(error)
|
||||||
|
} catch {
|
||||||
|
print("[Watch] refreshAll() network error: \(error)")
|
||||||
|
self.error = "network"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isRecoveryInProgress: Bool = false
|
||||||
|
|
||||||
|
func refreshAllWithRecovery() async {
|
||||||
|
guard !isRecoveryInProgress && !isLoading else {
|
||||||
|
print("[Watch] refreshAllWithRecovery() already in progress or refreshAll() running, skipping duplicate call")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isRecoveryInProgress = true
|
||||||
|
defer { isRecoveryInProgress = false }
|
||||||
|
|
||||||
|
reconcileSharedSessionState()
|
||||||
|
WatchL10n.shared.refreshFromiPhoneAndSharedState()
|
||||||
|
|
||||||
|
let sharedActiveStudentIdNorm = SharedSessionStateManager.shared.loadState()?.activeStudentIdNorm
|
||||||
|
let localStudentIdNorm = TokenManager.shared.loadToken()?.studentIdNorm
|
||||||
|
let shouldRequestTokenFromPhone =
|
||||||
|
!hasValidToken ||
|
||||||
|
(sharedActiveStudentIdNorm != nil && localStudentIdNorm != sharedActiveStudentIdNorm)
|
||||||
|
|
||||||
|
if shouldRequestTokenFromPhone {
|
||||||
|
WatchConnectivityManager.shared.requestTokenFromPhone()
|
||||||
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
checkTokenState()
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshAll()
|
||||||
|
|
||||||
|
guard error == "token_expired" || error == "no_token" else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[Watch] Token issue after refreshAll(), starting auto-recovery flow...")
|
||||||
|
let recovered = await attemptTokenRecovery()
|
||||||
|
if recovered {
|
||||||
|
await refreshAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles API errors and maps them to user-friendly messages
|
||||||
|
private func handleAPIError(_ error: APIError) {
|
||||||
|
print("[Watch] handleAPIError: \(error)")
|
||||||
|
switch error {
|
||||||
|
case .tokenError(let tokenError):
|
||||||
|
switch tokenError {
|
||||||
|
case .noToken:
|
||||||
|
print("[Watch] Setting error = no_token")
|
||||||
|
self.error = "no_token"
|
||||||
|
case .refreshExpired, .invalidGrant:
|
||||||
|
print("[Watch] Setting error = token_expired")
|
||||||
|
self.error = "token_expired"
|
||||||
|
case .invalidResponse, .networkError:
|
||||||
|
print("[Watch] Setting error = network (token error)")
|
||||||
|
self.error = "network"
|
||||||
|
}
|
||||||
|
case .unauthorized:
|
||||||
|
print("[Watch] Setting error = token_expired (unauthorized)")
|
||||||
|
self.error = "token_expired"
|
||||||
|
case .requestFailed(let statusCode):
|
||||||
|
if statusCode >= 500 {
|
||||||
|
print("[Watch] Setting error = api_error (server error \(statusCode))")
|
||||||
|
self.error = "api_error"
|
||||||
|
} else {
|
||||||
|
print("[Watch] Setting error = network (request failed \(statusCode))")
|
||||||
|
self.error = "network"
|
||||||
|
}
|
||||||
|
case .decodingFailed, .invalidURL:
|
||||||
|
print("[Watch] Setting error = network")
|
||||||
|
self.error = "network"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Processing
|
||||||
|
|
||||||
|
private func buildTimetableData(from lessons: [WidgetLesson]) -> TimetableData {
|
||||||
|
let today = Date()
|
||||||
|
let todayString = formatDateForComparison(today)
|
||||||
|
let tomorrowString = formatDateForComparison(today.addingTimeInterval(86400))
|
||||||
|
|
||||||
|
let todayLessons = lessons.filter { $0.date == todayString }.sorted { $0.start < $1.start }
|
||||||
|
let tomorrowLessons = lessons.filter { $0.date == tomorrowString }.sorted { $0.start < $1.start }
|
||||||
|
|
||||||
|
var nextSchoolDayLessons: [WidgetLesson]? = nil
|
||||||
|
var nextSchoolDayDateString: String? = nil
|
||||||
|
|
||||||
|
for daysOffset in 2...14 {
|
||||||
|
let checkDate = today.addingTimeInterval(TimeInterval(daysOffset * 86400))
|
||||||
|
let checkDateString = formatDateForComparison(checkDate)
|
||||||
|
let checkLessons = lessons.filter { $0.date == checkDateString }
|
||||||
|
|
||||||
|
if !checkLessons.isEmpty {
|
||||||
|
nextSchoolDayLessons = checkLessons.sorted { $0.start < $1.start }
|
||||||
|
nextSchoolDayDateString = checkDateString
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentBreak: BreakInfo? = nil
|
||||||
|
|
||||||
|
return TimetableData(
|
||||||
|
today: todayLessons,
|
||||||
|
tomorrow: tomorrowLessons,
|
||||||
|
nextSchoolDay: nextSchoolDayLessons,
|
||||||
|
nextSchoolDayDate: nextSchoolDayDateString,
|
||||||
|
currentBreak: currentBreak,
|
||||||
|
allLessons: lessons
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds AveragesData from grades (matching Flutter's calculation)
|
||||||
|
private func buildAveragesData(from grades: [WidgetGrade]) -> AveragesData {
|
||||||
|
guard !grades.isEmpty else {
|
||||||
|
return AveragesData(overall: nil, subjects: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
var subjectGradesMap: [String: [(value: Int, weight: Double)]] = [:]
|
||||||
|
|
||||||
|
for grade in grades {
|
||||||
|
if let numeric = grade.normalizedNumericValue {
|
||||||
|
let key = grade.subject.uid
|
||||||
|
let weight = Double(grade.weightPercentage ?? 100) / 100.0
|
||||||
|
subjectGradesMap[key, default: []].append((value: numeric, weight: weight))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var subjectAverages: [SubjectAverage] = []
|
||||||
|
|
||||||
|
for (uid, gradeValues) in subjectGradesMap {
|
||||||
|
if let firstGrade = grades.first(where: { $0.subject.uid == uid }) {
|
||||||
|
var weightedSum = 0.0
|
||||||
|
var totalWeight = 0.0
|
||||||
|
|
||||||
|
for (value, weight) in gradeValues {
|
||||||
|
weightedSum += Double(value) * weight
|
||||||
|
totalWeight += weight
|
||||||
|
}
|
||||||
|
|
||||||
|
let average = totalWeight > 0 ? weightedSum / totalWeight : Double.nan
|
||||||
|
|
||||||
|
if !average.isNaN {
|
||||||
|
subjectAverages.append(
|
||||||
|
SubjectAverage(
|
||||||
|
uid: uid,
|
||||||
|
name: firstGrade.subject.name,
|
||||||
|
average: average,
|
||||||
|
gradeCount: gradeValues.count
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let overall: Double?
|
||||||
|
if !subjectAverages.isEmpty {
|
||||||
|
let sumOfAverages = subjectAverages.reduce(0.0) { $0 + $1.average }
|
||||||
|
overall = sumOfAverages / Double(subjectAverages.count)
|
||||||
|
} else {
|
||||||
|
overall = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return AveragesData(overall: overall, subjects: subjectAverages)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getCurrentWeekDateRange() -> (start: Date, end: Date) {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let today = Date()
|
||||||
|
|
||||||
|
let weekday = calendar.component(.weekday, from: today)
|
||||||
|
let daysToMonday = weekday == 1 ? -6 : (2 - weekday)
|
||||||
|
let monday = calendar.date(byAdding: .day, value: daysToMonday, to: today)!
|
||||||
|
|
||||||
|
let nextSunday = calendar.date(byAdding: .day, value: 13, to: monday)!
|
||||||
|
|
||||||
|
return (monday, nextSunday)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDateForComparison(_ date: Date) -> String {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let components = calendar.dateComponents([.year, .month, .day], from: date)
|
||||||
|
return String(format: "%04d-%02d-%02d",
|
||||||
|
components.year ?? 0,
|
||||||
|
components.month ?? 0,
|
||||||
|
components.day ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed Helpers
|
||||||
|
|
||||||
|
var timeSinceUpdate: String? {
|
||||||
|
guard let lastUpdated = lastUpdated else { return nil }
|
||||||
|
|
||||||
|
let elapsed = Date().timeIntervalSince(lastUpdated)
|
||||||
|
|
||||||
|
if elapsed < 60 {
|
||||||
|
return "time_now".localized
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minutes
|
||||||
|
let minutes = Int(elapsed / 60)
|
||||||
|
if minutes < 60 {
|
||||||
|
return minutes == 1
|
||||||
|
? "time_since_minutes_one".localized
|
||||||
|
: "time_since_minutes_many".localized(minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hours
|
||||||
|
let hours = Int(elapsed / 3600)
|
||||||
|
if hours < 24 {
|
||||||
|
return hours == 1
|
||||||
|
? "time_since_hours_one".localized
|
||||||
|
: "time_since_hours_many".localized(hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Days
|
||||||
|
let days = Int(elapsed / 86400)
|
||||||
|
return days == 1
|
||||||
|
? "time_since_days_one".localized
|
||||||
|
: "time_since_days_many".localized(days)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if data is stale (> 1 hour old or never updated)
|
||||||
|
var isStale: Bool {
|
||||||
|
guard let lastUpdated = lastUpdated else { return true }
|
||||||
|
|
||||||
|
let elapsed = Date().timeIntervalSince(lastUpdated)
|
||||||
|
return elapsed > 3600 // 1 hour
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
import Foundation
|
||||||
|
import WatchConnectivity
|
||||||
|
|
||||||
|
class WatchConnectivityManager: NSObject, WCSessionDelegate {
|
||||||
|
static let shared = WatchConnectivityManager()
|
||||||
|
private let lastAppliedTokenUpdateKey = "watch_last_applied_token_update_ms"
|
||||||
|
private let minPhoneTokenRequestInterval: TimeInterval = 5
|
||||||
|
private var lastPhoneTokenRequestAt: Date?
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastAppliedTokenUpdateMs: Int64 {
|
||||||
|
get {
|
||||||
|
Int64(UserDefaults.standard.double(forKey: lastAppliedTokenUpdateKey))
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
UserDefaults.standard.set(Double(newValue), forKey: lastAppliedTokenUpdateKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extractSentAtMs(from authDict: [String: Any]) -> Int64? {
|
||||||
|
if let value = authDict["sentAtMs"] as? Int64 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if let value = authDict["sentAtMs"] as? Int {
|
||||||
|
return Int64(value)
|
||||||
|
}
|
||||||
|
if let value = authDict["sentAtMs"] as? Double {
|
||||||
|
return Int64(value)
|
||||||
|
}
|
||||||
|
if let value = authDict["sentAtMs"] as? String,
|
||||||
|
let parsed = Int64(value) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseInt64(_ value: Any?) -> Int64? {
|
||||||
|
if let value = value as? Int64 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if let value = value as? Int {
|
||||||
|
return Int64(value)
|
||||||
|
}
|
||||||
|
if let value = value as? Double {
|
||||||
|
return Int64(value)
|
||||||
|
}
|
||||||
|
if let value = value as? String, let parsed = Int64(value) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func activate() {
|
||||||
|
print("[Watch] WatchConnectivityManager.activate() called")
|
||||||
|
if WCSession.isSupported() {
|
||||||
|
print("[Watch] WCSession is supported, activating...")
|
||||||
|
WCSession.default.delegate = self
|
||||||
|
WCSession.default.activate()
|
||||||
|
} else {
|
||||||
|
print("[Watch] WCSession is NOT supported!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(
|
||||||
|
_ session: WCSession,
|
||||||
|
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||||
|
error: Error?
|
||||||
|
) {
|
||||||
|
print("[Watch] Session activation completed with state: \(activationState.rawValue)")
|
||||||
|
if let error = error {
|
||||||
|
print("[Watch] Activation error: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if activationState == .activated {
|
||||||
|
let context = session.receivedApplicationContext
|
||||||
|
if !context.isEmpty {
|
||||||
|
self.processApplicationContext(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(
|
||||||
|
_ session: WCSession,
|
||||||
|
didReceiveApplicationContext applicationContext: [String: Any]
|
||||||
|
) {
|
||||||
|
print("[Watch] didReceiveApplicationContext called")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.processApplicationContext(applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(
|
||||||
|
_ session: WCSession,
|
||||||
|
didReceiveUserInfo userInfo: [String: Any] = [:]
|
||||||
|
) {
|
||||||
|
print("[Watch] didReceiveUserInfo called")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.processUserInfo(userInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(
|
||||||
|
_ session: WCSession,
|
||||||
|
didReceiveMessage message: [String: Any],
|
||||||
|
replyHandler: @escaping ([String: Any]) -> Void
|
||||||
|
) {
|
||||||
|
print("[Watch] didReceiveMessage called: \(message)")
|
||||||
|
|
||||||
|
if let messageId = message["id"] as? String, messageId == "token_update" {
|
||||||
|
if let authDict = message["auth"] as? [String: Any] {
|
||||||
|
print("[Watch] Received immediate token_update via sendMessage")
|
||||||
|
processAuthData(authDict)
|
||||||
|
replyHandler(["success": true])
|
||||||
|
} else {
|
||||||
|
replyHandler(["error": "no_auth"])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let action = message["action"] as? String else {
|
||||||
|
replyHandler(["error": "no_action"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "getToken":
|
||||||
|
handleGetTokenRequest(replyHandler: replyHandler)
|
||||||
|
default:
|
||||||
|
replyHandler(["error": "unknown_action"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleGetTokenRequest(replyHandler: @escaping ([String: Any]) -> Void) {
|
||||||
|
guard TokenManager.shared.loadToken() != nil else {
|
||||||
|
print("[Watch] No token to send to iPhone")
|
||||||
|
replyHandler(["error": "no_token"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if TokenManager.shared.isTokenExpired() {
|
||||||
|
print("[Watch] Token expired, attempting refresh before sending to iPhone...")
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let freshToken = try await KretaAPIClient.shared.getValidToken()
|
||||||
|
print("[Watch] Token refresh succeeded, sending fresh token to iPhone")
|
||||||
|
|
||||||
|
var tokenData: [String: Any] = [
|
||||||
|
"studentId": freshToken.studentId,
|
||||||
|
"studentIdNorm": freshToken.studentIdNorm,
|
||||||
|
"iss": freshToken.iss,
|
||||||
|
"idToken": freshToken.idToken,
|
||||||
|
"accessToken": freshToken.accessToken,
|
||||||
|
"refreshToken": freshToken.refreshToken,
|
||||||
|
"expiryDate": Int64(freshToken.expiryDate.timeIntervalSince1970 * 1000)
|
||||||
|
]
|
||||||
|
if let tokenVersion = freshToken.effectiveTokenVersion {
|
||||||
|
tokenData["tokenVersion"] = tokenVersion
|
||||||
|
}
|
||||||
|
tokenData["updatedAtMs"] = freshToken.effectiveUpdatedAtMs ?? Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
|
||||||
|
replyHandler(["token": tokenData])
|
||||||
|
} catch {
|
||||||
|
print("[Watch] Token refresh failed after all retries: \(error)")
|
||||||
|
replyHandler(["error": "refresh_failed"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let token = TokenManager.shared.loadToken() else {
|
||||||
|
replyHandler(["error": "no_token"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenData: [String: Any] = [
|
||||||
|
"studentId": token.studentId,
|
||||||
|
"studentIdNorm": token.studentIdNorm,
|
||||||
|
"iss": token.iss,
|
||||||
|
"idToken": token.idToken,
|
||||||
|
"accessToken": token.accessToken,
|
||||||
|
"refreshToken": token.refreshToken,
|
||||||
|
"expiryDate": Int64(token.expiryDate.timeIntervalSince1970 * 1000)
|
||||||
|
]
|
||||||
|
if let tokenVersion = token.effectiveTokenVersion {
|
||||||
|
tokenData["tokenVersion"] = tokenVersion
|
||||||
|
}
|
||||||
|
tokenData["updatedAtMs"] = token.effectiveUpdatedAtMs ?? Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "HH:mm:ss"
|
||||||
|
formatter.timeZone = TimeZone.current
|
||||||
|
print("[Watch] Sending token to iPhone, expiry: \(formatter.string(from: token.expiryDate))")
|
||||||
|
replyHandler(["token": tokenData])
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestTokenFromPhone() {
|
||||||
|
guard WCSession.default.activationState == .activated else {
|
||||||
|
print("[Watch] Cannot request token: session not activated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard WCSession.default.isReachable else {
|
||||||
|
print("[Watch] Cannot request token: iPhone not reachable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
if let lastPhoneTokenRequestAt,
|
||||||
|
now.timeIntervalSince(lastPhoneTokenRequestAt) < minPhoneTokenRequestInterval {
|
||||||
|
print("[Watch] Skipping token request due to cooldown")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastPhoneTokenRequestAt = now
|
||||||
|
|
||||||
|
print("[Watch] Requesting token from iPhone...")
|
||||||
|
|
||||||
|
WCSession.default.sendMessage(
|
||||||
|
["action": "requestToken"],
|
||||||
|
replyHandler: { response in
|
||||||
|
print("[Watch] Received response from iPhone")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let authDict = response["auth"] as? [String: Any] {
|
||||||
|
print("[Watch] Token received from iPhone")
|
||||||
|
self.processAuthData(authDict)
|
||||||
|
} else if let error = response["error"] as? String {
|
||||||
|
print("[Watch] Token request error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
errorHandler: { error in
|
||||||
|
print("[Watch] Token request failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processApplicationContext(_ context: [String: Any]) {
|
||||||
|
if (context["force_logout"] as? Bool) == true {
|
||||||
|
print("[Watch] Received force_logout via applicationContext")
|
||||||
|
handleForceLogoutFromPhone()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let authDict = context["auth"] as? [String: Any] {
|
||||||
|
print("[Watch] Received auth from iPhone")
|
||||||
|
processAuthData(authDict)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let language = context["language"] as? String {
|
||||||
|
let sharedStateVersion =
|
||||||
|
parseInt64(context["language_state_version"]) ??
|
||||||
|
parseInt64(context["languageStateVersion"])
|
||||||
|
print("[Watch] Received language from iPhone: \(language)")
|
||||||
|
WatchL10n.shared.updateFromiPhone(
|
||||||
|
languageCode: language,
|
||||||
|
sharedStateVersion: sharedStateVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processUserInfo(_ userInfo: [String: Any]) {
|
||||||
|
if let messageId = userInfo["id"] as? String {
|
||||||
|
switch messageId {
|
||||||
|
case "token_update":
|
||||||
|
if let authDict = userInfo["auth"] as? [String: Any] {
|
||||||
|
print("[Watch] Received token_update via userInfo")
|
||||||
|
processAuthData(authDict)
|
||||||
|
}
|
||||||
|
case "language_update":
|
||||||
|
if let language = userInfo["language"] as? String {
|
||||||
|
let sharedStateVersion =
|
||||||
|
parseInt64(userInfo["language_state_version"]) ??
|
||||||
|
parseInt64(userInfo["languageStateVersion"])
|
||||||
|
print("[Watch] Received language_update via userInfo: \(language)")
|
||||||
|
WatchL10n.shared.updateFromiPhone(
|
||||||
|
languageCode: language,
|
||||||
|
sharedStateVersion: sharedStateVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case "reauth_required":
|
||||||
|
print("[Watch] Received reauth_required notification from iPhone")
|
||||||
|
DataStore.shared.setReauthRequired()
|
||||||
|
case "force_logout":
|
||||||
|
print("[Watch] Received force_logout notification from iPhone")
|
||||||
|
handleForceLogoutFromPhone()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleForceLogoutFromPhone() {
|
||||||
|
TokenManager.shared.deleteToken()
|
||||||
|
_ = SharedSessionStateManager.shared.publishState(
|
||||||
|
hasAnyAccount: false,
|
||||||
|
activeStudentIdNorm: nil
|
||||||
|
)
|
||||||
|
DataStore.shared.clearAll()
|
||||||
|
DataStore.shared.resetRecoveryState()
|
||||||
|
DataStore.shared.checkTokenState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendTokenToiPhoneInBackground() {
|
||||||
|
guard WCSession.default.activationState == .activated else {
|
||||||
|
print("[Watch] Cannot send token: session not activated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let token = TokenManager.shared.loadToken() else {
|
||||||
|
print("[Watch] No token to send to iPhone")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenData: [String: Any] = [
|
||||||
|
"studentId": token.studentId,
|
||||||
|
"studentIdNorm": token.studentIdNorm,
|
||||||
|
"iss": token.iss,
|
||||||
|
"idToken": token.idToken,
|
||||||
|
"accessToken": token.accessToken,
|
||||||
|
"refreshToken": token.refreshToken,
|
||||||
|
"expiryDate": Int64(token.expiryDate.timeIntervalSince1970 * 1000)
|
||||||
|
]
|
||||||
|
if let tokenVersion = token.effectiveTokenVersion {
|
||||||
|
tokenData["tokenVersion"] = tokenVersion
|
||||||
|
}
|
||||||
|
tokenData["updatedAtMs"] = token.effectiveUpdatedAtMs ?? Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try WCSession.default.updateApplicationContext(["auth": tokenData])
|
||||||
|
print("[Watch] Token sent via applicationContext")
|
||||||
|
} catch {
|
||||||
|
print("[Watch] Failed to update applicationContext: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
WCSession.default.transferUserInfo([
|
||||||
|
"id": "token_update_from_watch",
|
||||||
|
"auth": tokenData
|
||||||
|
])
|
||||||
|
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "HH:mm:ss"
|
||||||
|
formatter.timeZone = TimeZone.current
|
||||||
|
print("[Watch] Token sent to iPhone (background), expiry: \(formatter.string(from: token.expiryDate))")
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestLanguageFromPhone() {
|
||||||
|
guard WCSession.default.activationState == .activated else {
|
||||||
|
print("[Watch] Cannot request language: session not activated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard WCSession.default.isReachable else {
|
||||||
|
print("[Watch] Cannot request language: iPhone not reachable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[Watch] Requesting language from iPhone...")
|
||||||
|
|
||||||
|
WCSession.default.sendMessage(
|
||||||
|
["action": "requestLanguage"],
|
||||||
|
replyHandler: { response in
|
||||||
|
print("[Watch] Received language response from iPhone")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let language = response["language"] as? String {
|
||||||
|
let sharedStateVersion =
|
||||||
|
self.parseInt64(response["language_state_version"]) ??
|
||||||
|
self.parseInt64(response["languageStateVersion"])
|
||||||
|
print("[Watch] Language received from iPhone: \(language)")
|
||||||
|
WatchL10n.shared.updateFromiPhone(
|
||||||
|
languageCode: language,
|
||||||
|
sharedStateVersion: sharedStateVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
errorHandler: { error in
|
||||||
|
print("[Watch] Language request failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processAuthData(_ authDict: [String: Any]) {
|
||||||
|
print("[Watch] processAuthData called")
|
||||||
|
do {
|
||||||
|
let incomingSentAtMs = extractSentAtMs(from: authDict) ?? 0
|
||||||
|
let previousSentAtMs = lastAppliedTokenUpdateMs
|
||||||
|
|
||||||
|
if incomingSentAtMs > 0 && incomingSentAtMs < previousSentAtMs {
|
||||||
|
print("[Watch] Ignoring stale token_update (sentAtMs: \(incomingSentAtMs), lastApplied: \(previousSentAtMs))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let jsonData = try JSONSerialization.data(withJSONObject: authDict)
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .custom { decoder in
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
let timestamp = try container.decode(Int64.self)
|
||||||
|
return Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = try decoder.decode(WatchToken.self, from: jsonData)
|
||||||
|
let currentToken = TokenManager.shared.loadToken()
|
||||||
|
|
||||||
|
let isAccountSwitch = currentToken != nil && !token.isSameAccount(as: currentToken!)
|
||||||
|
let shouldForceAccountSwitch: Bool
|
||||||
|
if isAccountSwitch {
|
||||||
|
if incomingSentAtMs > 0 {
|
||||||
|
shouldForceAccountSwitch = true
|
||||||
|
} else {
|
||||||
|
shouldForceAccountSwitch = token.isNewer(than: currentToken!)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
shouldForceAccountSwitch = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if incomingSentAtMs <= 0,
|
||||||
|
let currentToken,
|
||||||
|
!isAccountSwitch,
|
||||||
|
!token.isNewer(than: currentToken) {
|
||||||
|
print("[Watch] Ignoring stale token_update without sentAtMs (same account, not newer)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[Watch] Token decoded, saving... (sentAtMs: \(incomingSentAtMs), forceSwitch: \(shouldForceAccountSwitch))")
|
||||||
|
|
||||||
|
try TokenManager.shared.saveToken(
|
||||||
|
token,
|
||||||
|
syncToSharedKeychain: false,
|
||||||
|
forceAccountSwitch: shouldForceAccountSwitch
|
||||||
|
)
|
||||||
|
print("[Watch] Token saved successfully")
|
||||||
|
_ = SharedSessionStateManager.shared.publishState(
|
||||||
|
hasAnyAccount: true,
|
||||||
|
activeStudentIdNorm: token.studentIdNorm
|
||||||
|
)
|
||||||
|
if incomingSentAtMs > 0 {
|
||||||
|
lastAppliedTokenUpdateMs = max(previousSentAtMs, incomingSentAtMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
DataStore.shared.clearError()
|
||||||
|
DataStore.shared.resetRecoveryState()
|
||||||
|
DataStore.shared.checkTokenState()
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await DataStore.shared.refreshAllWithRecovery()
|
||||||
|
print("[Watch] Data refresh completed")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("[Watch] Failed to process auth data: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
119
firka/ios/FirkaWatch Watch App/Views/GradeSubjectView.swift
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GradeSubjectView: View {
|
||||||
|
let subjectName: String
|
||||||
|
let grades: [WidgetGrade]
|
||||||
|
let average: Double
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
FirkaCard {
|
||||||
|
HStack {
|
||||||
|
Text("average".localized)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(String(format: "%.2f", average))
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundColor(averageColor(average))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(groupedGrades, id: \.date) { group in
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(formatDate(group.date))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
ForEach(group.grades) { grade in
|
||||||
|
gradeRow(grade)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle(subjectName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var groupedGrades: [(date: Date, grades: [WidgetGrade])] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let grouped = Dictionary(grouping: grades) { grade in
|
||||||
|
calendar.startOfDay(for: grade.recordDate)
|
||||||
|
}
|
||||||
|
return grouped
|
||||||
|
.map { (date: $0.key, grades: $0.value) }
|
||||||
|
.sorted { $0.date > $1.date }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func gradeRow(_ grade: WidgetGrade) -> some View {
|
||||||
|
FirkaCard {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
if let normalizedValue = grade.normalizedNumericValue {
|
||||||
|
if grade.isPercentageGrade, let rawValue = grade.numericValue {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(gradeColor(normalizedValue))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
Text("\(rawValue)%")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GradeBadge(grade: normalizedValue)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(grade.displayValue)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.padding(6)
|
||||||
|
.background(Color.gray)
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(grade.displayTypeWithWeight)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
|
||||||
|
if let topic = grade.topic, !topic.isEmpty {
|
||||||
|
Text(topic)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func gradeColor(_ value: Int) -> Color {
|
||||||
|
switch value {
|
||||||
|
case 5: return .green
|
||||||
|
case 4: return .blue
|
||||||
|
case 3: return .yellow
|
||||||
|
case 2: return .orange
|
||||||
|
default: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDate(_ date: Date) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy. MM. dd."
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func averageColor(_ avg: Double) -> Color {
|
||||||
|
switch avg {
|
||||||
|
case 4.5...: return .green
|
||||||
|
case 3.5..<4.5: return .blue
|
||||||
|
case 2.5..<3.5: return .yellow
|
||||||
|
case 1.5..<2.5: return .orange
|
||||||
|
default: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
108
firka/ios/FirkaWatch Watch App/Views/GradesView.swift
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GradesView: View {
|
||||||
|
let dataStore: DataStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
if dataStore.data == nil {
|
||||||
|
ContentUnavailableView("no_data".localized, systemImage: "graduationcap")
|
||||||
|
} else if subjects.isEmpty {
|
||||||
|
ContentUnavailableView("no_grades".localized, systemImage: "graduationcap")
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ForEach(subjects, id: \.uid) { subject in
|
||||||
|
NavigationLink {
|
||||||
|
GradeSubjectView(
|
||||||
|
subjectName: subject.name,
|
||||||
|
grades: gradesFor(subject.uid),
|
||||||
|
average: subject.average
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
subjectRow(subject)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let overall = dataStore.data?.averages.overall {
|
||||||
|
overallAverageCard(overall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var subjects: [SubjectAverage] {
|
||||||
|
(dataStore.data?.averages.subjects ?? []).sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func gradesFor(_ uid: String) -> [WidgetGrade] {
|
||||||
|
dataStore.data?.grades.filter { $0.subject.uid == uid } ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func subjectRow(_ subject: SubjectAverage) -> some View {
|
||||||
|
FirkaCard {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
Text(subject.name)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(String(format: "%.2f", subject.average))
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundColor(averageColor(subject.average))
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
AverageProgressBar(average: subject.average)
|
||||||
|
|
||||||
|
Text("grades_count".localized(subject.gradeCount))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func overallAverageCard(_ average: Double) -> some View {
|
||||||
|
FirkaCard {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("total_average".localized)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text(String(format: "%.2f", average))
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundColor(averageColor(average))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
AverageProgressBar(average: average)
|
||||||
|
.frame(width: 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func averageColor(_ avg: Double) -> Color {
|
||||||
|
switch avg {
|
||||||
|
case 4.5...: return .green
|
||||||
|
case 3.5..<4.5: return .blue
|
||||||
|
case 2.5..<3.5: return .yellow
|
||||||
|
case 1.5..<2.5: return .orange
|
||||||
|
default: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
598
firka/ios/FirkaWatch Watch App/Views/HomeView.swift
Normal file
@@ -0,0 +1,598 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WatchConnectivity
|
||||||
|
internal import Combine
|
||||||
|
|
||||||
|
struct HomeView: View {
|
||||||
|
let dataStore: DataStore
|
||||||
|
@State private var currentTime = Date()
|
||||||
|
|
||||||
|
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
if let breakInfo = dataStore.data?.timetable.currentBreak {
|
||||||
|
breakView(breakInfo)
|
||||||
|
} else if !dataStore.hasToken && dataStore.data == nil {
|
||||||
|
noTokenView
|
||||||
|
} else if let current = currentLesson {
|
||||||
|
currentLessonView(current)
|
||||||
|
} else if let next = nextLesson {
|
||||||
|
if isBreakBetweenLessons {
|
||||||
|
breakBetweenView(next)
|
||||||
|
} else {
|
||||||
|
beforeSchoolView(next)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
noMoreLessonsView
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshButton
|
||||||
|
|
||||||
|
if dataStore.lastUpdated != nil {
|
||||||
|
lastUpdatedView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.onReceive(timer) { _ in
|
||||||
|
currentTime = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Refresh Button
|
||||||
|
|
||||||
|
@State private var refreshStatus: RefreshStatus = .idle
|
||||||
|
@State private var wasLoadingFromBackground: Bool = false
|
||||||
|
@State private var lastUpdateTime: Date? = nil
|
||||||
|
|
||||||
|
enum RefreshStatus {
|
||||||
|
case idle, loading, success, failure
|
||||||
|
}
|
||||||
|
|
||||||
|
private var refreshButton: some View {
|
||||||
|
Button(action: {
|
||||||
|
guard !dataStore.isLoading else { return }
|
||||||
|
Task {
|
||||||
|
refreshStatus = .loading
|
||||||
|
await dataStore.refreshAllWithRecovery()
|
||||||
|
if dataStore.error == nil && dataStore.data != nil {
|
||||||
|
refreshStatus = .success
|
||||||
|
} else {
|
||||||
|
refreshStatus = .failure
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
refreshStatus = .idle
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if dataStore.isLoading && refreshStatus != .loading {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
} else {
|
||||||
|
switch refreshStatus {
|
||||||
|
case .idle:
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
case .loading:
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
case .success:
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
case .failure:
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(refreshStatusText)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(dataStore.isLoading || refreshStatus == .loading)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.onChange(of: dataStore.isLoading) { oldValue, newValue in
|
||||||
|
if newValue && refreshStatus != .loading {
|
||||||
|
wasLoadingFromBackground = true
|
||||||
|
}
|
||||||
|
if !newValue && wasLoadingFromBackground && refreshStatus != .loading {
|
||||||
|
wasLoadingFromBackground = false
|
||||||
|
if dataStore.error == nil && dataStore.data != nil {
|
||||||
|
refreshStatus = .success
|
||||||
|
} else if dataStore.error != nil {
|
||||||
|
refreshStatus = .failure
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
if refreshStatus == .success || refreshStatus == .failure {
|
||||||
|
refreshStatus = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: dataStore.lastUpdated) { oldValue, newValue in
|
||||||
|
guard let oldValue, let newValue else { return }
|
||||||
|
guard newValue > oldValue else { return }
|
||||||
|
guard dataStore.error == nil else { return }
|
||||||
|
guard refreshStatus != .loading else { return }
|
||||||
|
|
||||||
|
refreshStatus = .success
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
if refreshStatus == .success {
|
||||||
|
refreshStatus = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var refreshStatusText: String {
|
||||||
|
if dataStore.isLoading && refreshStatus != .loading {
|
||||||
|
return "refreshing".localized
|
||||||
|
}
|
||||||
|
|
||||||
|
switch refreshStatus {
|
||||||
|
case .idle: return "refresh".localized
|
||||||
|
case .loading: return "refreshing".localized
|
||||||
|
case .success: return "refresh_success".localized
|
||||||
|
case .failure:
|
||||||
|
if let error = dataStore.error {
|
||||||
|
switch error {
|
||||||
|
case "api_error": return "error_api".localized
|
||||||
|
case "network": return "error_network".localized
|
||||||
|
case "token_expired", "no_token": return "reauth_required".localized
|
||||||
|
default: return "refresh_failed".localized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "refresh_failed".localized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
private var now: Date { currentTime }
|
||||||
|
|
||||||
|
private var todayLessons: [WidgetLesson] {
|
||||||
|
let todayStr = formatDateForHomeView(currentTime)
|
||||||
|
|
||||||
|
if let allLessons = dataStore.data?.timetable.allLessons, !allLessons.isEmpty {
|
||||||
|
return allLessons
|
||||||
|
.filter { $0.date == todayStr }
|
||||||
|
.sorted { $0.start < $1.start }
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataStore.data?.timetable.today ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentLesson: WidgetLesson? {
|
||||||
|
todayLessons.first { currentTime >= $0.start && currentTime <= $0.end }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var nextLesson: WidgetLesson? {
|
||||||
|
todayLessons
|
||||||
|
.filter { $0.start > currentTime }
|
||||||
|
.sorted { $0.start < $1.start }
|
||||||
|
.first
|
||||||
|
}
|
||||||
|
|
||||||
|
private var previousLesson: WidgetLesson? {
|
||||||
|
todayLessons
|
||||||
|
.filter { $0.end < currentTime }
|
||||||
|
.sorted { $0.end > $1.end }
|
||||||
|
.first
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isBreakBetweenLessons: Bool {
|
||||||
|
guard let prev = previousLesson, let next = nextLesson else { return false }
|
||||||
|
return currentTime > prev.end && currentTime < next.start
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Current Lesson View (with CountdownRing)
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func currentLessonView(_ lesson: WidgetLesson) -> some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Text("current_lesson".localized)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
let totalMinutes = Int(lesson.end.timeIntervalSince(lesson.start) / 60)
|
||||||
|
let remaining = max(0, Int(lesson.end.timeIntervalSince(now) / 60))
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
CountdownRing(
|
||||||
|
totalMinutes: totalMinutes,
|
||||||
|
remainingMinutes: remaining,
|
||||||
|
label: "minutes".localized,
|
||||||
|
size: 56,
|
||||||
|
lineWidth: 6,
|
||||||
|
displayOffset: 1
|
||||||
|
)
|
||||||
|
.id("lesson-\(lesson.start.timeIntervalSince1970)")
|
||||||
|
FirkaCard(
|
||||||
|
isHighlighted: true,
|
||||||
|
backgroundColor: lessonCardBackgroundColor(
|
||||||
|
for: lesson,
|
||||||
|
isHighlighted: true
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
lessonTitleWithStatus(
|
||||||
|
lesson,
|
||||||
|
font: .subheadline,
|
||||||
|
weight: .semibold,
|
||||||
|
lineLimit: 2
|
||||||
|
)
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if let room = lesson.roomName {
|
||||||
|
Label(room, systemImage: "door.right.hand.closed")
|
||||||
|
}
|
||||||
|
Text(lesson.timeString)
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next lesson preview
|
||||||
|
if let next = nextLesson {
|
||||||
|
Text("next".localized)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
FirkaCard(backgroundColor: lessonCardBackgroundColor(for: next)) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
lessonTitleWithStatus(
|
||||||
|
next,
|
||||||
|
font: .subheadline,
|
||||||
|
weight: .regular,
|
||||||
|
lineLimit: 2
|
||||||
|
)
|
||||||
|
if let room = next.roomName {
|
||||||
|
Text(room)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(next.start, style: .time)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Break Between Lessons (with CountdownRing)
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func breakBetweenView(_ next: WidgetLesson) -> some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Text("break".localized)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
let remaining = max(0, Int(ceil(next.start.timeIntervalSince(now) / 60)))
|
||||||
|
let totalBreakMinutes: Int = {
|
||||||
|
guard let previous = previousLesson else { return max(remaining, 1) }
|
||||||
|
let breakSeconds = max(60, next.start.timeIntervalSince(previous.end))
|
||||||
|
return max(1, Int(ceil(breakSeconds / 60)))
|
||||||
|
}()
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
CountdownRing(
|
||||||
|
totalMinutes: totalBreakMinutes,
|
||||||
|
remainingMinutes: remaining,
|
||||||
|
label: "minutes".localized,
|
||||||
|
size: 56,
|
||||||
|
lineWidth: 6,
|
||||||
|
displayOffset: 1
|
||||||
|
)
|
||||||
|
.id("break-\(next.start.timeIntervalSince1970)")
|
||||||
|
|
||||||
|
FirkaCard(backgroundColor: lessonCardBackgroundColor(for: next)) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("next_lesson".localized(next.displayName))
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if let room = next.roomName {
|
||||||
|
Label(room, systemImage: "door.right.hand.closed")
|
||||||
|
}
|
||||||
|
Text(next.start, style: .time)
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Before School View
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func beforeSchoolView(_ first: WidgetLesson) -> some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("first_lesson".localized)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
FirkaCard(backgroundColor: lessonCardBackgroundColor(for: first)) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
lessonTitleWithStatus(
|
||||||
|
first,
|
||||||
|
font: .headline,
|
||||||
|
weight: .regular,
|
||||||
|
lineLimit: 2
|
||||||
|
)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
if let room = first.roomName {
|
||||||
|
Label(room, systemImage: "door.right.hand.closed")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(relativeTimeString(to: first.start))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !todayLessons.isEmpty {
|
||||||
|
Text("today_lessons_count".localized(todayLessons.count))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - No More Lessons View
|
||||||
|
|
||||||
|
private var noMoreLessonsView: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundColor(.green)
|
||||||
|
|
||||||
|
Text("no_more_lessons".localized)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if let (nextLesson, dayLabel) = nextSchoolDayFirstLesson {
|
||||||
|
Text(dayLabel)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
FirkaCard(backgroundColor: lessonCardBackgroundColor(for: nextLesson)) {
|
||||||
|
HStack {
|
||||||
|
lessonTitleWithStatus(
|
||||||
|
nextLesson,
|
||||||
|
font: .subheadline,
|
||||||
|
weight: .regular,
|
||||||
|
lineLimit: 2
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
Text(nextLesson.start, style: .time)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var nextSchoolDayFirstLesson: (lesson: WidgetLesson, label: String)? {
|
||||||
|
guard let allLessons = dataStore.data?.timetable.allLessons, !allLessons.isEmpty else {
|
||||||
|
if let tomorrow = dataStore.data?.timetable.tomorrow.first {
|
||||||
|
return (tomorrow, "tomorrow_first_lesson".localized)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = currentTime
|
||||||
|
let todayStr = formatDateForHomeView(now)
|
||||||
|
|
||||||
|
let futureLessons = allLessons.filter { $0.date > todayStr }
|
||||||
|
.sorted { $0.date < $1.date || ($0.date == $1.date && $0.start < $1.start) }
|
||||||
|
|
||||||
|
guard let firstFuture = futureLessons.first else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let label = labelForDate(firstFuture.date, relativeTo: now)
|
||||||
|
|
||||||
|
return (firstFuture, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDateForHomeView(_ date: Date) -> String {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let components = calendar.dateComponents([.year, .month, .day], from: date)
|
||||||
|
return String(format: "%04d-%02d-%02d",
|
||||||
|
components.year ?? 0,
|
||||||
|
components.month ?? 0,
|
||||||
|
components.day ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func labelForDate(_ dateStr: String, relativeTo: Date) -> String {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.timeZone = TimeZone.current
|
||||||
|
|
||||||
|
guard let targetDate = formatter.date(from: dateStr) else {
|
||||||
|
return "next_school_day".localized
|
||||||
|
}
|
||||||
|
|
||||||
|
let today = calendar.startOfDay(for: relativeTo)
|
||||||
|
let target = calendar.startOfDay(for: targetDate)
|
||||||
|
|
||||||
|
let daysDiff = calendar.dateComponents([.day], from: today, to: target).day ?? 0
|
||||||
|
|
||||||
|
switch daysDiff {
|
||||||
|
case 1:
|
||||||
|
return "tomorrow_first_lesson".localized
|
||||||
|
case 2...6:
|
||||||
|
let dayFormatter = DateFormatter()
|
||||||
|
let langCode = WatchL10n.shared.currentLanguage.rawValue
|
||||||
|
dayFormatter.locale = Locale(identifier: langCode)
|
||||||
|
dayFormatter.dateFormat = "EEEE"
|
||||||
|
let dayName = dayFormatter.string(from: targetDate).capitalized
|
||||||
|
return "day_first_lesson".localized(dayName)
|
||||||
|
default:
|
||||||
|
return "next_school_day".localized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func lessonTitleWithStatus(
|
||||||
|
_ lesson: WidgetLesson,
|
||||||
|
font: Font,
|
||||||
|
weight: Font.Weight = .regular,
|
||||||
|
lineLimit: Int = 2
|
||||||
|
) -> some View {
|
||||||
|
Text(lesson.displayName)
|
||||||
|
.font(font)
|
||||||
|
.fontWeight(weight)
|
||||||
|
.lineLimit(lineLimit)
|
||||||
|
.foregroundColor(lessonPrimaryTextColor(for: lesson))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lessonPrimaryTextColor(for lesson: WidgetLesson) -> Color {
|
||||||
|
if lesson.isCancelled {
|
||||||
|
return .red
|
||||||
|
}
|
||||||
|
if lesson.isSubstitution {
|
||||||
|
return .yellow
|
||||||
|
}
|
||||||
|
return .primary
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lessonCardBackgroundColor(
|
||||||
|
for lesson: WidgetLesson,
|
||||||
|
isHighlighted: Bool = false
|
||||||
|
) -> Color {
|
||||||
|
if lesson.isCancelled {
|
||||||
|
return Color.red.opacity(0.16)
|
||||||
|
}
|
||||||
|
if lesson.isSubstitution {
|
||||||
|
return Color.yellow.opacity(0.16)
|
||||||
|
}
|
||||||
|
if isHighlighted {
|
||||||
|
return Color.green.opacity(0.2)
|
||||||
|
}
|
||||||
|
return Color(white: 0.12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Break/Vacation View
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func breakView(_ breakInfo: BreakInfo) -> some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
let icon = SeasonalIconHelper.iconName(for: breakInfo.nameKey, season: nil)
|
||||||
|
let color = SeasonalIconHelper.iconColor(for: breakInfo.nameKey, season: nil)
|
||||||
|
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundColor(color)
|
||||||
|
|
||||||
|
Text(breakInfo.name)
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - No Token View
|
||||||
|
|
||||||
|
private var isWatchSystemPaired: Bool {
|
||||||
|
guard WCSession.isSupported() else { return false }
|
||||||
|
return WCSession.default.isCompanionAppInstalled
|
||||||
|
}
|
||||||
|
|
||||||
|
private var noTokenTitleKey: String {
|
||||||
|
isWatchSystemPaired ? "login_on_iphone" : "pair_with_iphone"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var noTokenDescriptionKey: String {
|
||||||
|
isWatchSystemPaired ? "open_and_login_on_iphone" : "open_firka_on_iphone"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var noTokenIconName: String {
|
||||||
|
isWatchSystemPaired
|
||||||
|
? "person.crop.circle.badge.exclamationmark"
|
||||||
|
: "iphone.and.arrow.right.inward"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var noTokenView: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: noTokenIconName)
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
|
||||||
|
Text(noTokenTitleKey.localized)
|
||||||
|
.font(.headline)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text(noTokenDescriptionKey.localized)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Last Updated View
|
||||||
|
|
||||||
|
private var lastUpdatedView: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if dataStore.isStale {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.yellow)
|
||||||
|
}
|
||||||
|
if let text = dataStore.timeSinceUpdate {
|
||||||
|
Text("updated".localized(text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Relative Time Helper
|
||||||
|
|
||||||
|
private func relativeTimeString(to date: Date) -> String {
|
||||||
|
let now = currentTime
|
||||||
|
let interval = date.timeIntervalSince(now)
|
||||||
|
|
||||||
|
guard interval > 0 else {
|
||||||
|
return "time_now".localized
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalMinutes = Int(interval / 60)
|
||||||
|
let hours = totalMinutes / 60
|
||||||
|
let minutes = totalMinutes % 60
|
||||||
|
|
||||||
|
if hours > 0 && minutes > 0 {
|
||||||
|
return "time_hours_minutes".localized(hours, minutes)
|
||||||
|
} else if hours > 0 {
|
||||||
|
return "time_hours".localized(hours)
|
||||||
|
} else {
|
||||||
|
return "time_minutes_only".localized(minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
firka/ios/FirkaWatch Watch App/Views/LessonDetailView.swift
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LessonDetailView: View {
|
||||||
|
let lesson: WidgetLesson
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
if let number = lesson.lessonNumber {
|
||||||
|
Text("lesson_number".localized(number))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.blue.opacity(0.2))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("\(formatTime(lesson.start)) - \(formatTime(lesson.end))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(lesson.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(3)
|
||||||
|
|
||||||
|
if lesson.isCancelled || lesson.isSubstitution {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if lesson.isCancelled {
|
||||||
|
Label("cancelled".localized, systemImage: "xmark.circle.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
if lesson.isSubstitution {
|
||||||
|
Label("substitution".localized, systemImage: "person.2.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
if lesson.isSubstitution, let substitute = lesson.substituteTeacher {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Label("teacher".localized, systemImage: "person.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
if let original = lesson.teacher {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(original)
|
||||||
|
.strikethrough()
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("→")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
Text(substitute)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
} else {
|
||||||
|
Text(substitute)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let teacher = lesson.teacher {
|
||||||
|
detailRow(icon: "person.fill", label: "teacher".localized, value: teacher)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let room = lesson.roomName {
|
||||||
|
detailRow(icon: "door.right.hand.closed", label: "room".localized, value: room)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let theme = lesson.theme, !theme.isEmpty {
|
||||||
|
detailRow(icon: "doc.text.fill", label: "topic".localized, value: theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle("lesson_details".localized)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func detailRow(icon: String, label: String, value: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Label(label, systemImage: icon)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text(value)
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatTime(_ date: Date) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "HH:mm"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
308
firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WatchConnectivity
|
||||||
|
|
||||||
|
struct ReauthRequiredView: View {
|
||||||
|
@State private var isSyncing = false
|
||||||
|
@State private var syncStatus: SyncStatus = .idle
|
||||||
|
var onTokenReceived: (() -> Void)?
|
||||||
|
|
||||||
|
enum SyncStatus {
|
||||||
|
case idle
|
||||||
|
case syncing
|
||||||
|
case success
|
||||||
|
case failed
|
||||||
|
case phoneNotReachable
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: statusIcon)
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundColor(statusColor)
|
||||||
|
.symbolEffect(.pulse, isActive: syncStatus == .syncing)
|
||||||
|
|
||||||
|
Text("reauth_required".localized)
|
||||||
|
.font(.headline)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text("reauth_description".localized)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
|
||||||
|
if let statusMessage = statusMessage {
|
||||||
|
Text(statusMessage)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(statusMessageColor)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: syncWithiPhone) {
|
||||||
|
HStack {
|
||||||
|
if syncStatus == .syncing {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
}
|
||||||
|
Text("sync_button".localized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(syncStatus == .success ? .green : .blue)
|
||||||
|
.disabled(syncStatus == .syncing)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusIcon: String {
|
||||||
|
switch syncStatus {
|
||||||
|
case .idle:
|
||||||
|
return "exclamationmark.arrow.circlepath"
|
||||||
|
case .syncing:
|
||||||
|
return "arrow.triangle.2.circlepath"
|
||||||
|
case .success:
|
||||||
|
return "checkmark.circle.fill"
|
||||||
|
case .failed:
|
||||||
|
return "xmark.circle.fill"
|
||||||
|
case .phoneNotReachable:
|
||||||
|
return "iphone.slash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusColor: Color {
|
||||||
|
switch syncStatus {
|
||||||
|
case .idle:
|
||||||
|
return .orange
|
||||||
|
case .syncing:
|
||||||
|
return .blue
|
||||||
|
case .success:
|
||||||
|
return .green
|
||||||
|
case .failed:
|
||||||
|
return .red
|
||||||
|
case .phoneNotReachable:
|
||||||
|
return .gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusMessage: String? {
|
||||||
|
switch syncStatus {
|
||||||
|
case .idle:
|
||||||
|
return nil
|
||||||
|
case .syncing:
|
||||||
|
return "syncing".localized
|
||||||
|
case .success:
|
||||||
|
return "sync_success".localized
|
||||||
|
case .failed:
|
||||||
|
return "sync_failed".localized
|
||||||
|
case .phoneNotReachable:
|
||||||
|
return "phone_not_reachable".localized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusMessageColor: Color {
|
||||||
|
switch syncStatus {
|
||||||
|
case .success:
|
||||||
|
return .green
|
||||||
|
case .failed, .phoneNotReachable:
|
||||||
|
return .red
|
||||||
|
default:
|
||||||
|
return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncWithiPhone() {
|
||||||
|
guard WCSession.default.activationState == .activated else {
|
||||||
|
syncStatus = .failed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard WCSession.default.isReachable else {
|
||||||
|
syncStatus = .phoneNotReachable
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncStatus = .syncing
|
||||||
|
|
||||||
|
WCSession.default.sendMessage(
|
||||||
|
["action": "requestToken"],
|
||||||
|
replyHandler: { response in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let authDict = response["auth"] as? [String: Any] {
|
||||||
|
print("[Watch] Token received from iPhone via reauth sync")
|
||||||
|
self.processAuthData(authDict)
|
||||||
|
|
||||||
|
if !TokenManager.shared.isTokenExpired() {
|
||||||
|
self.syncStatus = .success
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
|
self.onTokenReceived?()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("[Watch] Received token is already expired - iPhone needs reauth")
|
||||||
|
self.syncStatus = .failed
|
||||||
|
}
|
||||||
|
} else if let error = response["error"] as? String {
|
||||||
|
print("[Watch] iPhone returned error: \(error)")
|
||||||
|
|
||||||
|
if error == "needsReauth" || error == "no_token" {
|
||||||
|
self.sendWatchTokenToiPhone()
|
||||||
|
} else {
|
||||||
|
self.syncStatus = .failed
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("[Watch] No token in response - iPhone may need reauth")
|
||||||
|
self.syncStatus = .failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
errorHandler: { error in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
print("[Watch] Reauth sync failed: \(error.localizedDescription)")
|
||||||
|
self.syncStatus = .failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 15) {
|
||||||
|
if self.syncStatus == .syncing {
|
||||||
|
self.syncStatus = .failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendWatchTokenToiPhone() {
|
||||||
|
guard TokenManager.shared.loadToken() != nil else {
|
||||||
|
print("[Watch] No token to send to iPhone")
|
||||||
|
syncStatus = .failed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if TokenManager.shared.isTokenExpired() {
|
||||||
|
print("[Watch] Watch token is expired - attempting to refresh with retries...")
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
_ = try await KretaAPIClient.shared.getValidToken()
|
||||||
|
print("[Watch] Token refresh succeeded! Now sending to iPhone...")
|
||||||
|
await MainActor.run {
|
||||||
|
self.sendRefreshedTokenToiPhone()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("[Watch] Token refresh failed after all retries: \(error)")
|
||||||
|
await MainActor.run {
|
||||||
|
self.syncStatus = .failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendRefreshedTokenToiPhone()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendRefreshedTokenToiPhone() {
|
||||||
|
guard let token = TokenManager.shared.loadToken() else {
|
||||||
|
print("[Watch] No token after refresh")
|
||||||
|
syncStatus = .failed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[Watch] Sending Watch token to iPhone...")
|
||||||
|
|
||||||
|
var tokenData: [String: Any] = [
|
||||||
|
"studentId": token.studentId,
|
||||||
|
"studentIdNorm": token.studentIdNorm,
|
||||||
|
"iss": token.iss,
|
||||||
|
"idToken": token.idToken,
|
||||||
|
"accessToken": token.accessToken,
|
||||||
|
"refreshToken": token.refreshToken,
|
||||||
|
"expiryDate": Int64(token.expiryDate.timeIntervalSince1970 * 1000)
|
||||||
|
]
|
||||||
|
if let tokenVersion = token.effectiveTokenVersion {
|
||||||
|
tokenData["tokenVersion"] = tokenVersion
|
||||||
|
}
|
||||||
|
tokenData["updatedAtMs"] = token.effectiveUpdatedAtMs ?? Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
|
||||||
|
WCSession.default.sendMessage(
|
||||||
|
["action": "receiveTokenFromWatch", "token": tokenData],
|
||||||
|
replyHandler: { response in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let success = response["success"] as? Bool, success {
|
||||||
|
print("[Watch] iPhone accepted our token!")
|
||||||
|
self.syncStatus = .success
|
||||||
|
|
||||||
|
DataStore.shared.clearError()
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
|
self.onTokenReceived?()
|
||||||
|
}
|
||||||
|
} else if let error = response["error"] as? String {
|
||||||
|
print("[Watch] iPhone rejected our token: \(error)")
|
||||||
|
self.syncStatus = .failed
|
||||||
|
} else {
|
||||||
|
self.syncStatus = .failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
errorHandler: { error in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
print("[Watch] Failed to send token to iPhone: \(error)")
|
||||||
|
self.syncStatus = .failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processAuthData(_ authDict: [String: Any]) {
|
||||||
|
do {
|
||||||
|
func parseInt64(_ value: Any?) -> Int64? {
|
||||||
|
if let value = value as? Int64 { return value }
|
||||||
|
if let value = value as? Int { return Int64(value) }
|
||||||
|
if let value = value as? Double { return Int64(value) }
|
||||||
|
if let value = value as? String, let parsed = Int64(value) { return parsed }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let incomingSentAtMs = parseInt64(authDict["sentAtMs"]) ?? 0
|
||||||
|
let jsonData = try JSONSerialization.data(withJSONObject: authDict)
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .custom { decoder in
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
let timestamp = try container.decode(Int64.self)
|
||||||
|
return Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = try decoder.decode(WatchToken.self, from: jsonData)
|
||||||
|
let currentToken = TokenManager.shared.loadToken()
|
||||||
|
let shouldForceAccountSwitch: Bool
|
||||||
|
if incomingSentAtMs > 0,
|
||||||
|
let currentToken,
|
||||||
|
!token.isSameAccount(as: currentToken) {
|
||||||
|
shouldForceAccountSwitch = true
|
||||||
|
} else {
|
||||||
|
shouldForceAccountSwitch = false
|
||||||
|
}
|
||||||
|
|
||||||
|
try TokenManager.shared.saveToken(
|
||||||
|
token,
|
||||||
|
syncToSharedKeychain: false,
|
||||||
|
forceAccountSwitch: shouldForceAccountSwitch
|
||||||
|
)
|
||||||
|
|
||||||
|
DataStore.shared.checkTokenState()
|
||||||
|
DataStore.shared.clearError()
|
||||||
|
|
||||||
|
print("[Watch] Token saved via reauth sync")
|
||||||
|
} catch {
|
||||||
|
print("[Watch] Failed to process auth data: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ReauthRequiredView()
|
||||||
|
}
|
||||||
78
firka/ios/FirkaWatch Watch App/Views/SettingsView.swift
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@AppStorage("refreshInterval") private var refreshInterval: Int = 0
|
||||||
|
@State private var l10n = WatchL10n.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section("language".localized) {
|
||||||
|
Toggle("sync_with_iphone".localized, isOn: Binding(
|
||||||
|
get: { l10n.syncWithiPhone },
|
||||||
|
set: { l10n.syncWithiPhone = $0 }
|
||||||
|
))
|
||||||
|
|
||||||
|
if !l10n.syncWithiPhone {
|
||||||
|
Picker("language".localized, selection: Binding(
|
||||||
|
get: { l10n.currentLanguage },
|
||||||
|
set: { l10n.setLanguage($0) }
|
||||||
|
)) {
|
||||||
|
ForEach(WatchLanguage.allCases, id: \.self) { lang in
|
||||||
|
HStack {
|
||||||
|
Text(lang.flag)
|
||||||
|
Text(lang.displayName)
|
||||||
|
}
|
||||||
|
.tag(lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("refresh".localized) {
|
||||||
|
Picker("refresh_interval".localized, selection: $refreshInterval) {
|
||||||
|
Text("auto".localized).tag(0)
|
||||||
|
Text("15_minutes".localized).tag(15)
|
||||||
|
Text("30_minutes".localized).tag(30)
|
||||||
|
Text("1_hour".localized).tag(60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button("clear_cache".localized) {
|
||||||
|
clearCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("logout".localized, role: .destructive) {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Text("version".localized)
|
||||||
|
Spacer()
|
||||||
|
Text(appVersion)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("settings".localized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var appVersion: String {
|
||||||
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearCache() {
|
||||||
|
DataStore.shared.clearCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func logout() {
|
||||||
|
TokenManager.shared.deleteToken()
|
||||||
|
_ = SharedSessionStateManager.shared.publishState(
|
||||||
|
hasAnyAccount: false,
|
||||||
|
activeStudentIdNorm: nil
|
||||||
|
)
|
||||||
|
DataStore.shared.clearAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
369
firka/ios/FirkaWatch Watch App/Views/TimetableView.swift
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TimetableView: View {
|
||||||
|
let dataStore: DataStore
|
||||||
|
|
||||||
|
@State private var selectedDay: Int = 0
|
||||||
|
@State private var weekOffset: Int = 0
|
||||||
|
|
||||||
|
private var dayLabels: [String] {
|
||||||
|
[
|
||||||
|
"day_mon".localized,
|
||||||
|
"day_tue".localized,
|
||||||
|
"day_wed".localized,
|
||||||
|
"day_thu".localized,
|
||||||
|
"day_fri".localized
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
daySelector
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
|
lessonsContent
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
updateWeekAndDay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateWeekAndDay() {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
if shouldShowNextWeek() {
|
||||||
|
weekOffset = 1
|
||||||
|
selectedDay = findFirstSchoolDay(weekOffset: 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
weekOffset = 0
|
||||||
|
let weekday = calendar.component(.weekday, from: now)
|
||||||
|
let todayIndex = weekday - 2
|
||||||
|
|
||||||
|
if todayIndex < 0 || todayIndex > 4 {
|
||||||
|
selectedDay = findFirstSchoolDay(weekOffset: 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if areTodayLessonsDone(dayIndex: todayIndex) {
|
||||||
|
if let nextDay = findNextSchoolDay(after: todayIndex) {
|
||||||
|
selectedDay = nextDay
|
||||||
|
} else {
|
||||||
|
selectedDay = todayIndex
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedDay = todayIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func areTodayLessonsDone(dayIndex: Int) -> Bool {
|
||||||
|
let todayLessons = lessonsForDay(dayIndex)
|
||||||
|
guard !todayLessons.isEmpty else { return true }
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
let lastLesson = todayLessons.sorted { $0.end > $1.end }.first
|
||||||
|
return lastLesson.map { now > $0.end } ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findNextSchoolDay(after dayIndex: Int) -> Int? {
|
||||||
|
for day in (dayIndex + 1)...4 {
|
||||||
|
if !lessonsForDay(day).isEmpty {
|
||||||
|
return day
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findFirstSchoolDay(weekOffset: Int) -> Int {
|
||||||
|
let oldOffset = self.weekOffset
|
||||||
|
for day in 0...4 {
|
||||||
|
let lessons = lessonsForDayWithOffset(day, weekOffset: weekOffset)
|
||||||
|
if !lessons.isEmpty {
|
||||||
|
return day
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lessonsForDayWithOffset(_ day: Int, weekOffset: Int) -> [WidgetLesson] {
|
||||||
|
guard let data = dataStore.data else { return [] }
|
||||||
|
|
||||||
|
let allLessons: [WidgetLesson]
|
||||||
|
if let all = data.timetable.allLessons, !all.isEmpty {
|
||||||
|
allLessons = all
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetDateStr = getDateStringForDayWithOffset(day, weekOffset: weekOffset)
|
||||||
|
return allLessons.filter { $0.date == targetDateStr }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getDateStringForDayWithOffset(_ day: Int, weekOffset: Int) -> String {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
let weekday = calendar.component(.weekday, from: now)
|
||||||
|
let daysFromMonday = (weekday == 1) ? -6 : (2 - weekday)
|
||||||
|
|
||||||
|
guard let monday = calendar.date(byAdding: .day, value: daysFromMonday, to: now) else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalDaysToAdd = day + (weekOffset * 7)
|
||||||
|
guard let targetDate = calendar.date(byAdding: .day, value: totalDaysToAdd, to: monday) else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDate(targetDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldShowNextWeek() -> Bool {
|
||||||
|
guard let allLessons = dataStore.data?.timetable.allLessons, !allLessons.isEmpty else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
let calendar = Calendar.current
|
||||||
|
|
||||||
|
let weekday = calendar.component(.weekday, from: now)
|
||||||
|
let daysFromMonday = (weekday == 1) ? -6 : (2 - weekday)
|
||||||
|
guard let monday = calendar.date(byAdding: .day, value: daysFromMonday, to: now),
|
||||||
|
let friday = calendar.date(byAdding: .day, value: 4, to: monday) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let fridayString = formatDate(friday)
|
||||||
|
let mondayString = formatDate(monday)
|
||||||
|
|
||||||
|
let currentWeekLessons = allLessons.filter { lesson in
|
||||||
|
lesson.date >= mondayString && lesson.date <= fridayString
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !currentWeekLessons.isEmpty else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastLesson = currentWeekLessons
|
||||||
|
.sorted { $0.date > $1.date || ($0.date == $1.date && $0.end > $1.end) }
|
||||||
|
.first
|
||||||
|
|
||||||
|
guard let last = lastLesson else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return now > last.end
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Day Selector
|
||||||
|
|
||||||
|
private var daySelector: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(0..<5, id: \.self) { day in
|
||||||
|
Button(action: { selectedDay = day }) {
|
||||||
|
Text(dayLabels[day])
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 32)
|
||||||
|
.foregroundColor(selectedDay == day ? .white : .primary)
|
||||||
|
.background(selectedDay == day ? Color.blue : Color.clear)
|
||||||
|
.cornerRadius(8)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(isToday(day) && selectedDay != day ? Color.blue : Color.clear, lineWidth: 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isToday(_ day: Int) -> Bool {
|
||||||
|
guard weekOffset == 0 else { return false }
|
||||||
|
let weekday = Calendar.current.component(.weekday, from: Date())
|
||||||
|
return day == weekday - 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lessons Content
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var lessonsContent: some View {
|
||||||
|
let lessons = lessonsForDay(selectedDay)
|
||||||
|
|
||||||
|
if lessons.isEmpty {
|
||||||
|
freeDayView
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
ForEach(lessons) { lesson in
|
||||||
|
NavigationLink {
|
||||||
|
LessonDetailView(lesson: lesson)
|
||||||
|
} label: {
|
||||||
|
lessonRow(lesson)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lessonsForDay(_ day: Int) -> [WidgetLesson] {
|
||||||
|
guard let data = dataStore.data else { return [] }
|
||||||
|
|
||||||
|
let allLessons: [WidgetLesson]
|
||||||
|
if let all = data.timetable.allLessons, !all.isEmpty {
|
||||||
|
allLessons = all
|
||||||
|
} else {
|
||||||
|
var combined: [WidgetLesson] = []
|
||||||
|
combined.append(contentsOf: data.timetable.today)
|
||||||
|
combined.append(contentsOf: data.timetable.tomorrow)
|
||||||
|
if let nextSchoolDay = data.timetable.nextSchoolDay {
|
||||||
|
combined.append(contentsOf: nextSchoolDay)
|
||||||
|
}
|
||||||
|
allLessons = combined
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetDateStr = getDateStringForDay(day)
|
||||||
|
|
||||||
|
let uniqueDates = Set(allLessons.map { $0.date }).sorted()
|
||||||
|
print("[Watch] lessonsForDay: day=\(day), weekOffset=\(weekOffset), targetDate=\(targetDateStr), lessons=\(allLessons.count)")
|
||||||
|
print("[Watch] Unique dates in lessons: \(uniqueDates)")
|
||||||
|
|
||||||
|
if let first = allLessons.first {
|
||||||
|
let cal = Calendar.current
|
||||||
|
let comp = cal.dateComponents([.year, .month, .day, .hour, .minute], from: first.start)
|
||||||
|
print("[Watch] First lesson: date=\(first.date), start=\(comp.year!)-\(comp.month!)-\(comp.day!) \(comp.hour!):\(comp.minute!)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let filtered = allLessons.filter { $0.date == targetDateStr }
|
||||||
|
print("[Watch] Filtered lessons: \(filtered.count) for \(targetDateStr)")
|
||||||
|
|
||||||
|
return filtered.sorted { ($0.lessonNumber ?? 0) < ($1.lessonNumber ?? 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getDateStringForDay(_ day: Int) -> String {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
let weekday = calendar.component(.weekday, from: now)
|
||||||
|
let daysFromMonday = (weekday == 1) ? -6 : (2 - weekday)
|
||||||
|
|
||||||
|
guard let monday = calendar.date(byAdding: .day, value: daysFromMonday, to: now) else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalDaysToAdd = day + (weekOffset * 7)
|
||||||
|
guard let targetDate = calendar.date(byAdding: .day, value: totalDaysToAdd, to: monday) else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDate(targetDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDate(_ date: Date) -> String {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let components = calendar.dateComponents([.year, .month, .day], from: date)
|
||||||
|
return String(format: "%04d-%02d-%02d",
|
||||||
|
components.year ?? 0,
|
||||||
|
components.month ?? 0,
|
||||||
|
components.day ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var freeDayView: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "sun.max.fill")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundColor(.yellow)
|
||||||
|
|
||||||
|
Text("free_day".localized)
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lesson Row
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func lessonRow(_ lesson: WidgetLesson) -> some View {
|
||||||
|
FirkaCard(isHighlighted: lesson.isCurrentlyActive) {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
if let number = lesson.lessonNumber {
|
||||||
|
Text("\(number).")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.frame(width: 24, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(lesson.displayName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
if let statusIcon = lessonStatusIconName(for: lesson) {
|
||||||
|
Image(systemName: statusIcon)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(lessonStatusColor(for: lesson))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(lesson.start, style: .time)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if let teacher = lesson.teacher {
|
||||||
|
Text(teacher)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
if let room = lesson.roomName {
|
||||||
|
Text("•")
|
||||||
|
Text(room)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lessonStatusIconName(for lesson: WidgetLesson) -> String? {
|
||||||
|
if lesson.isCancelled {
|
||||||
|
return "xmark.circle.fill"
|
||||||
|
}
|
||||||
|
if lesson.isSubstitution {
|
||||||
|
return "exclamationmark.circle.fill"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lessonStatusColor(for lesson: WidgetLesson) -> Color {
|
||||||
|
lesson.isCancelled ? .red : .yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
struct TimetableView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
TimetableView(dataStore: DataStore.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "watchos",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
369
firka/ios/FirkaWatchComplications/FirkaComplications.swift
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
#if os(watchOS)
|
||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Complication Localization Helper
|
||||||
|
|
||||||
|
private struct ComplicationL10n {
|
||||||
|
private static let appGroupID = "group.app.firka.firkaa"
|
||||||
|
|
||||||
|
enum Language: String {
|
||||||
|
case hungarian = "hu"
|
||||||
|
case english = "en"
|
||||||
|
case german = "de"
|
||||||
|
}
|
||||||
|
|
||||||
|
static var currentLanguage: Language {
|
||||||
|
guard let defaults = UserDefaults(suiteName: appGroupID) else {
|
||||||
|
return .hungarian
|
||||||
|
}
|
||||||
|
let code = defaults.string(forKey: "watch_language") ?? "hu"
|
||||||
|
return Language(rawValue: code) ?? .hungarian
|
||||||
|
}
|
||||||
|
|
||||||
|
static func string(_ key: String) -> String {
|
||||||
|
switch currentLanguage {
|
||||||
|
case .hungarian: return hungarianStrings[key] ?? key
|
||||||
|
case .english: return englishStrings[key] ?? key
|
||||||
|
case .german: return germanStrings[key] ?? key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let hungarianStrings: [String: String] = [
|
||||||
|
"current_lesson": "Jelenlegi óra",
|
||||||
|
"next": "Következő",
|
||||||
|
"no_more_lessons": "Nincs több óra",
|
||||||
|
"average_abbrev": "Átl",
|
||||||
|
"next_lesson_title": "Következő óra",
|
||||||
|
"average_title": "Átlag",
|
||||||
|
"lesson_inline": "Óra (inline)"
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let englishStrings: [String: String] = [
|
||||||
|
"current_lesson": "Current Lesson",
|
||||||
|
"next": "Next",
|
||||||
|
"no_more_lessons": "No more lessons",
|
||||||
|
"average_abbrev": "Avg",
|
||||||
|
"next_lesson_title": "Next Lesson",
|
||||||
|
"average_title": "Average",
|
||||||
|
"lesson_inline": "Lesson (inline)"
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let germanStrings: [String: String] = [
|
||||||
|
"current_lesson": "Aktuelle Stunde",
|
||||||
|
"next": "Nächste",
|
||||||
|
"no_more_lessons": "Keine Stunden mehr",
|
||||||
|
"average_abbrev": "Ø",
|
||||||
|
"next_lesson_title": "Nächste Stunde",
|
||||||
|
"average_title": "Durchschnitt",
|
||||||
|
"lesson_inline": "Stunde (inline)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Watch Cache Loader
|
||||||
|
|
||||||
|
private struct WatchCacheLoader {
|
||||||
|
private static let appGroupID = "group.app.firka.firkaa"
|
||||||
|
private static let cacheFileName = "watch_data.json"
|
||||||
|
|
||||||
|
static func loadWidgetData() -> WidgetData? {
|
||||||
|
guard let containerURL = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: appGroupID
|
||||||
|
) else {
|
||||||
|
print("[WatchComplication] No App Group container")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileURL = containerURL.appendingPathComponent(cacheFileName)
|
||||||
|
|
||||||
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||||
|
print("[WatchComplication] Cache file not found: \(fileURL.path)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let data = try? Data(contentsOf: fileURL) else {
|
||||||
|
print("[WatchComplication] Could not read cache file")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
|
||||||
|
struct CachedWatchData: Codable {
|
||||||
|
let widgetData: WidgetData
|
||||||
|
let lastUpdated: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let cached = try decoder.decode(CachedWatchData.self, from: data)
|
||||||
|
print("[WatchComplication] Loaded cache from \(cached.lastUpdated)")
|
||||||
|
return cached.widgetData
|
||||||
|
} catch {
|
||||||
|
print("[WatchComplication] Failed to decode: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timeline Entry
|
||||||
|
|
||||||
|
struct FirkaTimelineEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let data: WidgetData?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timeline Provider
|
||||||
|
|
||||||
|
struct FirkaTimelineProvider: TimelineProvider {
|
||||||
|
func placeholder(in context: Context) -> FirkaTimelineEntry {
|
||||||
|
FirkaTimelineEntry(date: Date(), data: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSnapshot(in context: Context, completion: @escaping (FirkaTimelineEntry) -> Void) {
|
||||||
|
let data = WatchCacheLoader.loadWidgetData()
|
||||||
|
completion(FirkaTimelineEntry(date: Date(), data: data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTimeline(in context: Context, completion: @escaping (Timeline<FirkaTimelineEntry>) -> Void) {
|
||||||
|
let data = WatchCacheLoader.loadWidgetData()
|
||||||
|
let entry = FirkaTimelineEntry(date: Date(), data: data)
|
||||||
|
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
let hour = calendar.component(.hour, from: now)
|
||||||
|
let weekday = calendar.component(.weekday, from: now)
|
||||||
|
let isSchoolHours = (weekday >= 2 && weekday <= 6) && (hour >= 6 && hour <= 16)
|
||||||
|
|
||||||
|
let refreshInterval: TimeInterval = isSchoolHours ? 15 * 60 : 60 * 60
|
||||||
|
let nextRefresh = now.addingTimeInterval(refreshInterval)
|
||||||
|
|
||||||
|
let timeline = Timeline(entries: [entry], policy: .after(nextRefresh))
|
||||||
|
completion(timeline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Next Lesson Complication (accessoryRectangular)
|
||||||
|
|
||||||
|
struct NextLessonComplication: Widget {
|
||||||
|
let kind = "NextLessonComplication"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: FirkaTimelineProvider()) { entry in
|
||||||
|
NextLessonView(entry: entry)
|
||||||
|
.containerBackground(.fill.tertiary, for: .widget)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(ComplicationL10n.string("next_lesson_title"))
|
||||||
|
.description("Shows the current or next lesson.")
|
||||||
|
.supportedFamilies([.accessoryRectangular])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NextLessonView: View {
|
||||||
|
let entry: FirkaTimelineEntry
|
||||||
|
|
||||||
|
private var now: Date { Date() }
|
||||||
|
|
||||||
|
private var todayLessons: [WidgetLesson] {
|
||||||
|
(entry.data?.timetable.today ?? []).sorted { $0.start < $1.start }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentLesson: WidgetLesson? {
|
||||||
|
todayLessons.first { now >= $0.start && now <= $0.end }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var nextLesson: WidgetLesson? {
|
||||||
|
todayLessons.first { $0.start > now }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let breakInfo = entry.data?.timetable.currentBreak {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: SeasonalIconHelper.iconName(for: breakInfo.nameKey, season: nil))
|
||||||
|
.font(.caption)
|
||||||
|
Text(breakInfo.name)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else if let lesson = currentLesson {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(ComplicationL10n.string("current_lesson"))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(lesson.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(1)
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if let room = lesson.roomName {
|
||||||
|
Image(systemName: "door.right.hand.closed")
|
||||||
|
.font(.caption2)
|
||||||
|
Text(room)
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
Text("→ \(lesson.end, formatter: Self.timeFormatter)")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else if let lesson = nextLesson {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(ComplicationL10n.string("next"))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(lesson.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(1)
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if let room = lesson.roomName {
|
||||||
|
Image(systemName: "door.right.hand.closed")
|
||||||
|
.font(.caption2)
|
||||||
|
Text(room)
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
Text(lesson.start, formatter: Self.timeFormatter)
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else if entry.data != nil {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text(ComplicationL10n.string("no_more_lessons"))
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Image(systemName: "book.fill")
|
||||||
|
.font(.title3)
|
||||||
|
Text("Firka")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let timeFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "HH:mm"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Average Complication (accessoryCircular)
|
||||||
|
|
||||||
|
struct AverageComplication: Widget {
|
||||||
|
let kind = "AverageComplication"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: FirkaTimelineProvider()) { entry in
|
||||||
|
AverageView(entry: entry)
|
||||||
|
.containerBackground(.fill.tertiary, for: .widget)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(ComplicationL10n.string("average_title"))
|
||||||
|
.description("Shows the overall grade average.")
|
||||||
|
.supportedFamilies([.accessoryCircular])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AverageView: View {
|
||||||
|
let entry: FirkaTimelineEntry
|
||||||
|
|
||||||
|
private var averageColor: Color {
|
||||||
|
guard let avg = entry.data?.averages.overall else { return .gray }
|
||||||
|
switch avg {
|
||||||
|
case 4.5...: return .green
|
||||||
|
case 3.5..<4.5: return .blue
|
||||||
|
case 2.5..<3.5: return .yellow
|
||||||
|
case 1.5..<2.5: return .orange
|
||||||
|
default: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let average = entry.data?.averages.overall {
|
||||||
|
Gauge(value: average, in: 1...5) {
|
||||||
|
Text(ComplicationL10n.string("average_abbrev"))
|
||||||
|
} currentValueLabel: {
|
||||||
|
Text(String(format: "%.1f", average))
|
||||||
|
.font(.system(.body, design: .rounded, weight: .bold))
|
||||||
|
}
|
||||||
|
.gaugeStyle(.accessoryCircular)
|
||||||
|
.tint(averageColor)
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
Text("—")
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Inline Complication (accessoryInline)
|
||||||
|
|
||||||
|
struct InlineComplication: Widget {
|
||||||
|
let kind = "InlineComplication"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: FirkaTimelineProvider()) { entry in
|
||||||
|
InlineView(entry: entry)
|
||||||
|
.containerBackground(.fill.tertiary, for: .widget)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(ComplicationL10n.string("lesson_inline"))
|
||||||
|
.description("One-line summary of the next lesson.")
|
||||||
|
.supportedFamilies([.accessoryInline])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct InlineView: View {
|
||||||
|
let entry: FirkaTimelineEntry
|
||||||
|
|
||||||
|
private var now: Date { Date() }
|
||||||
|
|
||||||
|
private var todayLessons: [WidgetLesson] {
|
||||||
|
(entry.data?.timetable.today ?? []).sorted { $0.start < $1.start }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentOrNextLesson: WidgetLesson? {
|
||||||
|
todayLessons.first { now >= $0.start && now <= $0.end }
|
||||||
|
?? todayLessons.first { $0.start > now }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let breakInfo = entry.data?.timetable.currentBreak {
|
||||||
|
Text(breakInfo.name)
|
||||||
|
} else if let lesson = currentOrNextLesson {
|
||||||
|
Text("\(lesson.displayName) \(lesson.start, formatter: Self.timeFormatter)")
|
||||||
|
} else if entry.data != nil {
|
||||||
|
Text(ComplicationL10n.string("no_more_lessons"))
|
||||||
|
} else {
|
||||||
|
Text("Firka")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let timeFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "HH:mm"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget Bundle
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct FirkaWatchWidgets: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
NextLessonComplication()
|
||||||
|
AverageComplication()
|
||||||
|
InlineComplication()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
11
firka/ios/FirkaWatchComplications/Info.plist
Normal file
@@ -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>
|
||||||
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,35 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "tinted"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||