219 Commits

Author SHA1 Message Date
Horváth Gergely
dd3884de16 Remove onAppOpened and related LiveActivity state
Remove the _lastActivityRecreation field and the onAppOpened(...) method from LiveActivityService, which previously handled ending/creating activities, refreshing push tokens, fetching the week's timetable and scheduling background fetch. Update home_screen to call LiveActivityService.checkAndUpdateTimetable(...) instead of onAppOpened, and tweak related log messages to clarify the behavior. This simplifies LiveActivity lifecycle on app resume and stops forced activity recreation/token refresh performed by the removed method.
2026-02-27 22:50:32 +01:00
Horváth Gergely
c646ea2d51 Improve Watch sync, token, and live activity handling
Multiple fixes and improvements for watch <> phone sync, token recovery, and live activity behavior:

- WatchSessionManager: add mergeApplicationContext to avoid clobbering app context, add thread-safe pending auth queue and flush, add sendMessageToWatch API, ensure message handling runs on main thread, add reply-timeout logic for language requests, support fire-and-forget messages, and improve enqueue/flush logic.
- WatchConnectivityManager & SettingsView: publish shared session state on force-logout/logout and improve account-switch/token handling; clear DataStore error and reset recovery state after token updates.
- DataStore & WatchL10n: add recovery-in-progress guard to avoid duplicate recovery runs, reset language version tracking on account switch, make WatchL10n.setLanguage main-thread safe.
- TokenManager & SharedKeychainManager: remove old keychain observer plumbing and instead publish shared session state when active token changes or is deleted.
- UI tweaks: reduce icon/text sizes and spacing in pairing view; only show sync button when paired; Settings logout now also publishes shared state.
- Watch sync wiring in Flutter: replace direct watch_connectivity usage with a MethodChannel-backed WatchSyncHelper.sendMessageToWatch and onWatchMessage callback; main and pairing UI updated accordingly.
- Kreta client: replace simple boolean mutex with a Completer-based mutex and timeout handling to avoid busy-waiting.
- LiveActivityService: throttle/avoid frequent activity recreation (cache last recreation time), skip placeholder creation when called from background, and minor cache-clearing adjustments.
- HomeScreen: add WidgetsBindingObserver to manage lifecycle, prevent prefetch while backgrounded, debounce prefetch, ensure LiveActivity registration runs once and refresh on resume after first prefetch.

These changes increase robustness of token sync and account switching, reduce race conditions and duplicate work, and avoid conflicts between WCSession and Flutter plugin delegates.
2026-02-27 22:44:53 +01:00
Horváth Gergely
a22459794a Refactor timetable day selection and parsing
Add robust date parsing and simplify lesson selection logic. Introduces ISO8601 formatters (with and without fractional seconds) and updates parseNextSchoolDayDate to try both variants, plus existing yyyy-MM-dd fallback. Adds LessonCandidate, startOfDay and nextSchoolDay helpers and builds a sorted candidate list (today, tomorrow, next school day) to pick the appropriate lesson set and correctly set isNextDay/isNextSchoolDay flags. Cleans up previous branching-heavy logic and improves handling of edge cases (lessons finished, next-school-day resolution).
2026-02-24 14:38:06 +01:00
Horváth Gergely
91a526703e Improve watch UI and robust language sync
Several watch UI and sync improvements: clamp CountdownRing values and use clamped values for progress/color/display; add optional backgroundColor to FirkaCard and refactor HomeView to use lessonTitleWithStatus, lessonCardBackgroundColor and improved break time calculation and refresh handling (onChange of lastUpdated). TimetableView now shows status icons/colors via helper methods. DataStore now returns a localized "time_now" for recent updates. WatchSessionManager handles Flutter channel/unready states by serving shared language state when available and avoids empty language replies. Dart fixes: await initLang in settings, return null from WatchSyncHelper when uninitialized, and attempt to publish language to watch after initialization in main.
2026-02-20 11:03:47 +01:00
Horváth Gergely
38ff8af578 Use zip to iterate adjacent lessons
Replace index-based loops with zip(entry.lessons, entry.lessons.dropFirst()) in TimetableMediumView and TimetableLargeView to iterate adjacent lesson pairs. This improves readability and safety (avoids manual index arithmetic and potential out-of-bounds issues) while keeping the same break-detection logic.
2026-02-16 20:16:09 +01:00
Horváth Gergely
58c16e9aa8 Add shared session/language state & refresh leases
Introduce shared session and language state plus cross-device refresh leases to improve Watch/iPhone sync. Adds SharedSessionStateManager, SharedLanguageStateManager and RefreshLeaseManager (keychain-backed) and exposes accessGroup via SharedKeychainManager. Wire shared state into WatchSessionManager (publish/load language and session state, immediate token send via message + userInfo + applicationContext, reachability check, lease-related Flutter handlers) and into WatchConnectivityManager (parse versions, apply immediate token updates). Update DataStore and WatchL10n to reconcile shared session/language state, handle stale account switching, and request tokens from phone when needed. Add TokenManager watch-side lease wrapper for refresh coordination, UI tweaks for pairing/no-token messages and icons, small fixes (date parsing in widget provider, background refresh cadence to ~15 min, time-since localization keys and usage), and various helper utilities for parsing int64 and state versioning.
2026-02-16 19:05:17 +01:00
Horváth Gergely
748bff63ea Send language to Watch and handle locale changes
iOS: Update WatchSessionManager to send the current language via WCSession.default.updateApplicationContext with logging and error handling, in addition to existing transferUserInfo. Dart: await initLang during startup and add dispatcher.onLocaleChanged handler that re-initializes language when the app is in auto-language mode, logs locale changes, and triggers a global UI update.
2026-02-15 23:22:32 +01:00
Horváth Gergely
812c1a008e Migrate token storage to shared Keychain
Add SharedKeychainManager and migrate token storage from the old iCloud Key-Value (KV) approach to a synchronized Keychain-backed solution. Replace iCloudTokenManager usages across WatchSessionManager, TokenManager, and WatchConnectivityManager, update saveToken API to syncToSharedKeychain, and add KV-store migration logic that moves existing KV entries into the shared Keychain and clears the old KV store. Update entitlements (add keychain-access-groups) for both Runner and the Watch app, add/remove files in the Xcode project, delete iCloudTokenManager.swift. Also include access-group resolution, logging, and compatibility observer methods in the new manager.
2026-02-13 23:29:19 +01:00
Horváth Gergely
b71aa12751 Improve token recovery and iCloud probing
Track and surface classified token recovery failures and add a timed iCloud probe to accelerate recovery. TokenManager: introduce lastRecoveryFailure, clearLastRecoveryFailure(), and iCloudProbeTimeoutNs; add probeICloudTokenWithTimeout() and attempt an early iCloud-probe apply before refresh flow; record TokenError results from refresh attempts and abort recovery early on network errors; clear failure on successful actions; default lastRecoveryFailure to .noToken when all attempts fail. KretaAPIClient: clear failure on valid token/recovery success and throw a classified APIError when recovery produced a known TokenError. Overall: better diagnostics, faster iCloud-based short-circuiting, and more robust recovery error handling.
2026-02-12 13:50:14 +01:00
Horváth Gergely
c16cbdb186 Add robust Apple Watch token sync & force-logout
Improve Apple Watch sync resilience and token safety across iOS and Flutter. Key changes:

- Watch (watchOS): rate-limit phone token requests, handle "force_logout" via applicationContext/userInfo and perform local token deletion/cleanup. Added cooldown tracking.
- iPhone host (Swift): expose Flutter methods to check watch app installation, clear iCloud token, and send force-logout to the watch; refuse to forward expired tokens and avoid using iCloud fallback when Flutter reports reauth needed.
- Flutter (WatchSyncHelper/KretaClient): cache and check whether a paired Watch app is installed before touching iCloud/watch; reject expired access tokens (both incoming and outgoing) and prevent sending expired tokens to watch; added fresh-install cleanup to clear iCloud/local state once per install; added methods to notify watch of forced logout and to clear iCloud token.
- Initialization: run fresh-install cleanup before attempting iCloud recovery and skip recovery if cleanup ran.
- Login/settings/home UI: only attempt watch sync when a watch is installed; clear iCloud token on account removal (iOS); minor UX/timing and formatting cleanups.

These changes prevent propagation of expired tokens, reduce redundant phone/watch messaging, and provide a controlled force-logout flow for account removal or fresh installs.
2026-02-11 20:42:15 +01:00
Horváth Gergely
8f28fa328c Add force account-switch and iCloud recovery
Introduce forced account-switch handling and improve token selection/recovery logic.

- WatchConnectivityManager & ReauthRequiredView: parse sentAtMs robustly and compute a shouldForceAccountSwitch flag when an incoming token is for a different account; pass forceAccountSwitch to TokenManager.saveToken and include it in logs.
- TokenManager: add localTokenFromKeychainAndFile helper, prefer the active account when selecting a local token, and refine loadToken to prefer active-account tokens but fall back to the freshest available. saveToken now accepts forceAccountSwitch and will persist a token even if it switches accounts when requested. Adjust iCloud handling to skip iCloud token only if a local active-account token exists; improve diagnostic logging.
- WatchToken: enhance isNewer comparison to consider effectiveUpdatedAt and tokenVersion before falling back to expiryDate.
- HomeScreen (phone): import watch_sync_helper and run a secondary iCloud recovery pass on startup (with a short delay) to apply a fresher iCloud token to the app state if needed.

These changes aim to make cross-device token sync more robust, correctly handle account switches triggered from the watch, and recover stale auth state from iCloud on app startup.
2026-02-11 13:09:32 +01:00
Horváth Gergely
8af53422dc Merge branch 'dev' of https://github.com/hgeryy2004/firka into dev 2026-02-11 11:33:43 +01:00
Horváth Gergely
dda4bfd9d3 Improve iCloud / Watch token sync & recovery
Add robust handling for token synchronization and recovery across iOS/watchOS and Flutter.

- iOS (WatchSessionManager.swift): queue token events until Flutter signals watchSyncReady, flush queued auth events and iCloud recovery notifications when ready, forward tokens with de-duplication logic, provide fallback to iCloud when Flutter isn't ready, and avoid sending duplicate events. Adds helper methods for token payloads and comparison.
- Token management (TokenManager.swift): track active studentIdNorm in UserDefaults to prefer a single account, persist active account on saves, prefer freshest token within preferred account, improve proactive refresh with configurable lead time and cooldown, skip unnecessary recoveries when a valid token exists, and add cooldown for phone recovery requests.
- iCloud logic (iCloudTokenManager.swift): avoid overwriting tokens across accounts using updatedAt comparisons and only ignore truly stale saves.
- Dart client (kreta_client.dart): skip recovery if local token still valid, clearer iCloud recovery retry flow, fallbacks, and clear reauth flag when usable token applied.
- Token model (token_model.dart & generated .g.dart): add tokenVersion and updatedAtMs fields, populate them on creation/from response, and update Isar schema + generated query helpers.
- Watch sync helper (watch_sync_helper.dart): include tokenVersion/updatedAt in outgoing payloads, resolve incoming/current versions more robustly, use updatedAt when deciding freshness, and invoke watchSyncReady on init so native side can flush queued events.
- App init (main.dart): run an iCloud recovery check on iOS during startup and only clear reauth flag when the chosen token is still in the future.

These changes improve cross-device token consistency, reduce spurious reauth prompts, and make proactive refresh/recovery less noisy and more efficient.
2026-02-11 11:33:11 +01:00
B3ni
d92e420b34 Update main.dart 2026-02-11 11:27:04 +01:00
Horváth Gergely
b54fa36671 Token recovery and sync refactor; LiveActivity UI
Centralize and harden token recovery and synchronization across watch/phone components and update LiveActivity UI for iOS 18.

Highlights:
- Introduced a centralized recovery flow in TokenManager.recoverToken() with locking, multi-step recovery (local refresh, keychain/file/watch, iCloud retries) and refreshTokenInternal helper. Added proactive refresh and improved refresh logic to include tokenVersion/updatedAtMs metadata.
- Added tokenVersion and updatedAtMs to WatchToken and propagated these fields through iCloud, WatchConnectivity, WatchSessionManager, and ReauthRequiredView payloads. Many save/load paths now avoid unnecessary iCloud sync and ignore stale tokens based on version/timestamps.
- WatchConnectivityManager now filters stale incoming updates using sentAtMs and persists lastAppliedTokenUpdateMs to avoid regressions; payloads include updatedAtMs/tokenVersion when available.
- DataStore and KretaAPIClient use the centralized recovery API instead of ad-hoc retry logic or direct WatchConnectivity requests.
- iCloudTokenManager stores tokenVersion/updatedAtMs, ignores stale saves, and uses consistent timestamps.
- LiveActivityWidget and LiveActivity updated: iOS 18+ uses new adaptive/family-aware views (including a new SmallActivityView) while legacy behavior is preserved for iOS 16.2–17.x.

These changes aim to make token synchronization more reliable across devices and OS versions, reduce duplicate/conflicting writes, and provide a more adaptive live activity UI on newer iOS releases.
2026-02-11 10:37:14 +01:00
Horváth Gergely
60375e93d1 Add active account handling & token recovery
Introduce active account selection and robust token recovery across watch and phone code. Adds a new Dart helper (active_account_helper.dart) to resolve the active account/token and updates KretaClient, token refresh/grant flows, live activity, watch sync, main init and home UI to use pickActiveToken. On the watch side, implement refreshAllWithRecovery in DataStore and replace direct refreshAll calls with refreshAllWithRecovery everywhere (ContentView, BackgroundRefreshManager, WatchConnectivityManager, HomeView). Rewrite the token recovery logic to retry (with delays), prefer fresher iCloud/file/keychain tokens, attempt iPhone retrieval, and better distinguish transient network errors from permanent token invalidation. Also update TokenManager to choose the freshest local token between keychain and file, and add small sync/NULL-safety fixes and delay tweaks in home_screen to improve iCloud recovery behavior. These changes aim to avoid unnecessary reauth prompts, handle intermittent network issues, and support multi-account setups by always using the active token.
2026-02-10 15:04:32 +01:00
Horváth Gergely
eb1e4b4cfd Handle iCloud token recovery and notify Flutter
Add end-to-end handling for tokens recovered from iCloud: TokenManager now detects fresh iCloud tokens and posts a "TokenRecoveredFromiCloud" Notification on iOS; WatchSessionManager observes that notification and invokes the Flutter method "onTokenRecoveredFromiCloud". On the Dart side, WatchSyncHelper handles the new callback and runs checkAndRecoverFromiCloud (clearing the reauth flag when appropriate). HomeScreen now attempts proactive iCloud recovery on iOS during startup when the token is expired/expiring or reauth is required, retrying with incremental delays and updating the client/token cache on success. Also minor log text tweak in main.dart and necessary imports added to home_screen.dart.
2026-02-09 20:29:06 +01:00
Horváth Gergely
f4eb4e7487 Add auto-refresh and duplicate-call guard
Watch app: add Combine import, scenePhase tracking, a periodic timer and a 10-minute freshness threshold to auto-refresh data when the app becomes active or data becomes stale. Move refresh logic into onChange/onReceive handlers and add shouldAutoRefresh to avoid unnecessary refreshes; removed an immediate proactive token refresh in ContentView. DataStore.refreshAll now early-returns if a refresh is already in progress and logs the skip to prevent duplicate concurrent refreshes.
2026-02-09 18:23:55 +01:00
Horváth Gergely
584f340778 Handle percentage grades and token recovery
Add support for percentage-style grades and normalize them to standard grade scale across the watch app and shared models; show raw percentage in grade badge UI while keeping normalized coloring. Update WidgetGrade/KretaGrade to include valueType, add isPercentageGrade, percentageToGrade, normalizedNumericValue, displayTypeWithWeight and displayGradeValue helpers, and use normalized values in DataStore and views. Improve HomeView refresh button to reflect background loading, disable during datastore loading, and auto-transition success/failure states when background sync finishes. Enhance token recovery logic in Dart: KretaClient attempts watch/iCloud recovery twice (with a short delay) before forcing reauth, and token_grant tries to recover a fresher token from iCloud via WatchSyncHelper on 401 responses before throwing token errors.
2026-02-09 15:34:14 +01:00
Horváth Gergely
b9de46f0ed Add iCloud token sync & improve recovery
Add iCloud-based token recovery and saving across iOS/watch components. Introduces WatchSyncHelper.checkAndRecoverFromiCloud and saveTokenToiCloud to fetch/save fresher tokens via a method channel, calls the check on app init and before proactive refresh, and attempts to save refreshed tokens to iCloud. Adds native handlers in WatchSessionManager (checkiCloudToken, saveTokeToniCloud) and updates DataStore token recovery to distinguish network errors vs permanently invalid tokens (avoid unnecessary reauth). Update logging messages accordingly. Also update ubiquity-kvstore identifiers in entitlements to use explicit group.app.firka.firkaa and bump iOS build/version (CURRENT_PROJECT_VERSION and CFBundleVersion) to 1068.
2026-02-08 16:18:28 +01:00
Horváth Gergely
0f3dcf58a5 Add splash assets and watch token recovery
Add Android and iOS launch/splash assets and flutter_native_splash config, and update launch_background.xml/styles for Android 12 support and dark mode.

Implement robust Apple Watch token recovery and refresh behavior: add recovery state and async recovery flow to DataStore (iCloud check, API refresh, request from iPhone), show a recovering UI in ContentView, attempt proactive recoveries on startup, and add WatchToken/iCloudTokenManager model support. WatchConnectivityManager now attempts to refresh expired tokens before replying to iPhone requests. BackgroundRefreshManager scheduling was improved to choose intervals based on timetable and user settings with debug logs. SettingsView defaults to Auto (0) and adds the Auto picker option and localization strings.

Also update entitlements across watch/widget extensions to include com.apple.developer.ubiquity-kvstore-identifier, rename watch app icon entry, and add project.pbxproj references for new source files.
2026-02-06 22:23:18 +01:00
Horváth Gergely
b58e60a1f8 Enhance reauthentication process with token recovery from Apple Watch 2026-02-05 22:12:43 +01:00
Horváth Gergely
42b8eea0ba Improve watch sync error handling and reauthentication logic 2026-02-05 18:24:02 +01:00
Horváth Gergely
ce9781f1c0 Fix typo in import statement
Corrects a misspelled 'mport SwiftUI' to 'import SwiftUI' in LessonCard.swift, restoring the proper SwiftUI import and preventing compilation errors.
2026-02-05 16:04:54 +01:00
Horváth Gergely
e5224cbfff Add Apple Watch app and watch sync support
Add a new Firka Watch app target with UI components, views and services: DataStore, BackgroundRefreshManager, WatchConnectivity/WatchSession integration, localization (WatchL10n), entitlements and assets. Move widget/shared models into ios/Shared and update Xcode project/schemes; add native Kreta API client and TokenManager for watch use. Implement watch-side caching, proactive token refresh, background scheduling, and pairing/pairing UI. Update Flutter side (watch_sync_helper, main, API/token helper changes) and tweak iOS .gitignore and project metadata to enable the watch integration and data sync between phone and watch.
2026-02-05 16:01:58 +01:00
Horváth Gergely
2d14c41070 Add Shortcuts intents, entities & localizations
Introduce App Shortcuts and AppIntents support: add multiple shortcut intents (next/closest lesson, today/tomorrow/closest timetable, recent grades, subject/overall averages), AppShortcuts provider, and ShortcutError. Add AppEntity types (Average, Grade, Lesson, Subject) and corresponding entity queries. Refactor Controls to use AppIntent-based navigation (OpenHome/OpenGrades/OpenTimetable) with localized labels/descriptions and remove duplicate SubjectEntity from AveragesIntent. Update TimetableProvider to use a lock-screen-friendly timeline refresh policy. Add localization resources (AppShortcuts.strings and expanded Localizable.strings) for en/de/hu and register new strings in project files; update Info.plist/project.pbxproj accordingly.
2026-01-31 11:52:01 +01:00
Horváth Gergely
ecb1745d9e Show active break in timetable widget
Add break detection and UI for active breaks in timetable widgets. Introduces a new localization key "break_between" and a BreakIndicatorRow view that displays a break marker and minutes left. TimetableMediumView and TimetableLargeView now detect when the current time falls between consecutive lessons, reduce the number of visible lessons accordingly, and inject a BreakIndicatorRow between lessons when in a break (minutes are calculated using ceil of time interval). Also clean up commented/debug lines in the iOS widget cache helper (widget.dart) for locale/theme and removed a TODO comment.
2026-01-30 20:44:48 +01:00
Horváth Gergely
0abc568a64 Add next-school-day support to iOS widgets
Add support for showing the next school day in widgets. Changes include: new localization keys (next_school_day_timetable, no_lessons_ahead) and a formatShortDate helper; updated Hungarian hours abbreviation. Widget model updated (TimetableData) to include nextSchoolDay and nextSchoolDayDate. TimetableProvider now sets isNextSchoolDay/nextSchoolDayDate and returns an entry when a future school day is found. Views (inline, lock screen, small/medium/large) updated to display the next school day header/date and show no_lessons_ahead where appropriate. Flutter helpers (widget cache/IOS helper) extended to serialize nextSchoolDayLessons/nextSchoolDayDate and to search up to a week ahead for the next school day when tomorrow is empty.
2026-01-30 20:25:02 +01:00
Horváth Gergely
0845290929 Add logging, await token callback and widget refresh
Fixes async token handling and improves observability: await the callback in KretaClient to ensure proper async flow, add informative logs for token expiry/refresh and warn on empty 200/201 API responses. Enhance token refresh flow in token_grant with detailed info/warning/severe logs and exception logging for easier debugging. Update widget DB helper to force fresh fetches for timetables/grades when updating widget cache, avoid writing empty caches and add debug prints for counts and cache status. Wire widget refresh into LiveActivityService background fetch (with import and try/catch logging) so iOS widgets get refreshed during background processing.
2026-01-30 10:41:26 +01:00
Horváth Gergely
3a0eb5fe54 Add iOS Control widgets & timetable UI updates
Introduce iOS 18 Control widgets and improve timetable/widget UI and behavior.

- Add Home, Grades and Timetable Control widgets (AppIntents) that write a "controlNavigation" value into the app group (group.app.firka.firkaa) so the main app can open the requested page.
- Register control widgets in HomeWidgetsBundle for iOS 18+.
- Extend WidgetLocalization with short/abbrev strings (tomorrow_short, hours_abbrev, in_hours) and related localization keys.
- Enhance Lock Screen and Timetable widgets to handle next-day lessons, show hours when >60 minutes, and use localized hour/minute strings.
- Update TimetableProvider timeline generation to avoid producing per-minute entries far before the next lesson; if the next lesson is >60 minutes away, add only a single entry ~60 minutes before it.
- Adjust various widget views (Averages, Grades, Timetable) for more compact layout: spacing, font weights/sizes, compact modes, and added average color logic.
- Update project.pbxproj to add a file-system-synchronized exception for the HomeWidgetsExtension files in the Runner target.
- AppDelegate: read and remove "controlNavigation" from the shared app group and return it via the Flutter method channel as a pending deep link; fall back to existing pendingWidgetDeepLink.
- Flutter: map a received 'home' deep link to HomePage.home in the home screen navigation switch.

These changes add Control Center / Lock Screen launch shortcuts and refine widget presentation and timeline performance.
2026-01-29 23:48:59 +01:00
Horváth Gergely
503a51ca23 Add lock-screen/inline widgets & timeline updates
Introduce Lock Screen and Inline variants for Timetable, Grades and Averages widgets and register them in HomeWidgetsBundle. Add new views and widget configurations (accessoryInline, accessoryCircular, accessoryRectangular) under LockScreen/, plus grade badge and average display UI. Extend Localization with many new keys in Helpers/Localization.swift and add lock-screen descriptions in en/de/hu Localizable.strings. Update TimetableProvider timeline logic to generate more frequent (per-minute) update entries for lock-screen families, deduplicate timeline dates and ensure proper midnight transition. Adjust TimetableViews display logic to prefer next lesson when current is absent and fix displayed label selection. Apply project.pbxproj updates to file-exception/group entries and some build phase metadata.
2026-01-29 13:36:11 +01:00
Horváth Gergely
0781685015 iOS: rename LiveActivity widget & update configs
Rename TimetableWidget to LiveActivityWidget and clean up HomeWidgetsExtension targets/files. Update entitlements (group id and formatting) and remove duplicate HomeWidgetsExtensionExtension entitlements file. Update Runner.xcodeproj: bump objectVersion, rename targets/products/references, add WidgetExtension.xcconfig, switch entitlements/infoplist paths, set live-activity related flags, adjust deployment targets/locales and MARKETING_VERSION/CURRENT_PROJECT_VERSION to use Flutter variables. Change app display name to "Firka Testing" and move CFBundleVersion in Info.plist. Also change default widget style to .liquidGlass for Averages, Grades and Timetable intents and fix null-safety when serializing subject/grade category fields in ios_widget_helper.dart.
2026-01-29 02:33:39 +01:00
Horváth Gergely
2a4836c42f Improve iOS widgets: UI, timeline and JSON fixes
Multiple widget improvements and bug fixes:

- Models: WidgetGrade now exposes subjectNameWithWeight and teacherName for display.
- AveragesProvider: added isFiltered flag, improved subject filtering logic and propagated flag to entries.
- Averages views: show filtered count badge, use configurable max visible items, and respect isFiltered.
- Grades views: display subject name with weight, show teacher in grade rows (renamed flag to showTeacher) and preserve topic display.
- TimetableProvider: build unique transition times for timeline updates, sort entries, and determine current lesson using the provided date instead of Date().
- Timetable views: compute visibleLessons window around the current lesson based on entry.date and use isLessonActive checks against entry.date.
- iOS widget JSON helper: fix subject.category serialization structure, use grade.creationDate for recordDate, and use grade.teacher for teacherName.

These changes fix incorrect JSON exports, improve timeline accuracy, and enhance widget UX when subjects are filtered or when showing teacher/weight info.
2026-01-28 23:37:27 +01:00
Horváth Gergely
4ff6f2fdb0 Add deep link handling for widgets; implement navigation from widget links 2026-01-27 22:22:10 +01:00
Horváth Gergely
f80ce9bc4f Add iOS Home Widgets Extension with timetable, grades, and averages
Introduces a new HomeWidgetsExtension for iOS, including widgets for timetable, recent grades, and subject averages. Adds widget models, providers, intents, localization, and SwiftUI views. Updates project files and main app to support widget data sharing and communication. Also includes new Dart helper for widget data and updates to relevant Flutter screens.
2026-01-27 18:58:25 +01:00
Horváth Gergely
91bf7a359c Add tokenExpired property and update warning display logic in live activity 2025-12-30 04:03:09 +01:00
Horváth Gergely
6d7d3641ea Add user-specific bell delay settings; implement retrieval and storage in SharedPreferences 2025-12-29 22:07:13 +01:00
Horváth Gergely
873e0f209b Add morning notification settings; implement user-specific preferences for time and enabled state 2025-12-24 20:44:40 +01:00
Horváth Gergely
8d768ca6b8 Implement token expiration handling and reauthentication UI; add reauth toast and update seasonal icon logic 2025-12-19 14:51:12 +01:00
Horváth Gergely
229eabfd4f Refactor logging in live activity manager and backend client; streamline lesson data handling and improve break event detection logic 2025-12-18 00:33:13 +01:00
Horváth Gergely
80599c13d8 Refactor lesson filtering and add logic to handle break events in live activity manager 2025-12-15 23:06:28 +01:00
Horváth Gergely
c92e83aadd Enhance time formatting by adding language detection for compact time and seasonal break methods 2025-12-13 03:54:25 +01:00
Horváth Gergely
47670fb558 Add time formatting helper and update seasonal display logic 2025-12-13 03:31:03 +01:00
Horváth Gergely
b8058cd4cb Add morning notification settings and debounce handling for Live Activities 2025-12-12 23:59:32 +01:00
Horváth Gergely
4fd3e2a09b Refactor date calculations for start of the week in live activity service 2025-12-08 09:41:06 +01:00
Horváth Gergely
0e0fa549cf Reduce bellDelay debounce interval from 5 seconds to 3 seconds;
Enhance lesson fetching logic for improved notification scheduling
2025-12-04 13:54:30 +01:00
39e9c097a0 Update project settings and entitlements for improved configuration
- Bump object version in project.pbxproj
- Refactor TimetableWidget folder exceptions in project.pbxproj
- Add BGTaskSchedulerPermittedIdentifiers to Info.plist
- Update development team and product bundle identifiers in entitlements
- Mark subproject as dirty in l10n
2025-11-26 16:33:37 +01:00
Horváth Gergely
ea8315a993 - Fixed UI/UX bugs in the Live Activities
- Fixed the unhandled language get, when the Live Activity is turned off
- Fixed bellDelay bugs
2025-11-26 05:05:14 +01:00
Horváth Gergely
6d33f6b0d8 - Added bellDelay support for Live Activities; both standard push notifications and Live Activities now use the bellDelay value (default: 0). IMPORTANT: Holidays and school break(spring,summer,autumn,winter) activities do not apply bellDelay.
- Implemented background fetch on iOS: the app now refreshes every 30 minutes in the background and sends timetable changes to the backend if any are detected.
2025-11-25 19:07:30 +01:00
Horváth Gergely
8c4bbd0905 - Fixed user switching so previous user data is now properly cleared.
- Fixed re-login behavior: after every new login, the Live Activities privacy notice is shown as intended.
- Made the Live Activity toggle user-specific to prevent settings from carrying over after switching accounts.
- Fixed user logout so the backend now correctly removes all related data.
- Fixed first-install behavior so the Live Activities privacy notice no longer appears on the public beta screen; it now only appears after reaching the home page.
2025-11-25 14:18:23 +01:00
Horváth Gergely
fe70fc7bd1 - Fixed lesson language data; backend now passes the correct values.
- Updated handling of cancelled lessons: removed timer and now displaying the localized “Cancelled” text.
- Aligned the icon and lesson number in the Dynamic Island so they are now level with the label text.
2025-11-24 21:21:12 +01:00
Horváth Gergely
eb3ed957f1 Add data privacy consent dialog for Live Activity
A new data privacy consent dialog has been added to the Live Activity feature. Users must accept this dialog to use Live Activity. If they decline, all their data will be immediately deleted from the database, and cannot use the Live Activities feature. Additionally, users receive a detailed description explaining what data we store, for how long, and their GDPR rights.
2025-11-24 04:43:14 +01:00
cd525898bb Merge branch 'dev' of https://github.com/hgeryy2004/firka into dev 2025-11-20 09:00:31 +01:00
0e08919209 feat: add logging for device registration and timetable updates 2025-11-19 15:58:53 +01:00
e651552dec fix: enable iOS platform and update build settings for arm64 architecture 2025-11-19 15:49:28 +01:00
8f683561b9 fix: update version number to 1.0.9+1030 2025-11-19 15:46:25 +01:00
519c9a4043 fix: update development team and bundle identifiers 2025-11-19 13:41:11 +01:00
Horváth Gergely
626d6aefdd Added an example of the .env file. 2025-11-19 13:36:35 +01:00
Horváth Gergely
887d765f65 Live activity support added!
I have merged the latest repo myself and decided to publish my version to the repo.
2025-11-19 01:57:24 +01:00
Pearoo
34cf77f216 feat: add error page 2025-11-07 15:25:48 +01:00
7b382563ba Jenkinsfile: fdroid -> artifacts 2025-10-26 09:14:10 +01:00
667a8e0e4d refactor: use streams for fetching data 2025-10-26 09:08:05 +01:00
147dff3696 fix: payload cast 2025-10-12 16:53:56 +02:00
0fd36de4a3 Increment base build number
Since we rebased the commit count is smaller
2025-10-12 15:50:53 +02:00
d4fe91860a cast jwt payload to Map<String, dynamic> 2025-10-12 13:57:20 +02:00
45d0b298c4 settings/licenses: fix text & spinner style 2025-10-11 20:14:13 +02:00
c812b0721f update l10n module 2025-10-11 20:07:18 +02:00
b62140c0d0 Fordítás hozzáadása 2025-10-09 22:21:28 +02:00
f55cc65f07 License menü 2025-10-09 20:57:55 +02:00
3690cf0462 Fordítás, License menü 2025-10-09 20:57:23 +02:00
233f0c9ed0 Órarend kinézet javítás 2025-10-09 20:56:59 +02:00
6912e44b7e Órarenden igazítás 2025-10-08 20:50:13 +02:00
369febba91 Discord, PP átirányítás 2025-10-08 20:50:01 +02:00
2ca5e8c54b Helyettesítések elrejtése, Discord + PP átirányítás 2025-10-08 20:49:35 +02:00
dd515955ba Helyettesítések elrejtése 2025-10-08 20:48:31 +02:00
97d1f005b4 Amennyiben nem írt dolgozatot/szöveges értékelés stb, nem 0-val jelzi, hanem szöveges értékelést mutatja. 2025-10-07 22:06:37 +02:00
92e94f60c5 Időformátum szebbítése 2025-10-07 22:05:46 +02:00
ba53f30cce kicsi hiba fix 2025-10-07 20:48:03 +02:00
a34c8e23d3 Funkció hozzáadása: Dolgozat popup 2025-10-07 20:39:22 +02:00
ab1208999d Amennyiben nincs jegy 1 tantárgyból, jelzi dave svg-vel 2025-10-06 21:03:58 +02:00
2b51523eb1 Tantárgy név átvitele 2025-10-06 21:03:29 +02:00
9f3190495b Felesleges import törlése 2025-10-06 21:03:12 +02:00
14fdf00259 Felesleges import törlése + tantárgy név átvitel 2025-10-06 21:02:37 +02:00
815604d144 tt: localize "Tantárgy megtekintése" btn 2025-10-06 16:57:54 +02:00
8f499aac21 tt: implement view subject button 2025-10-06 16:56:08 +02:00
f24f340d63 + Tantárgy megtekintése gomb 2025-10-06 12:38:52 +02:00
ccf7443f26 Számonkérés megjelenítése az adott óra felugró ablakánál 2025-10-06 12:37:57 +02:00
2f0c6ca9df Üres napnál amennyiben pld tanítás nélküli munkanap, akkor jelzi a felhasználónak 2025-10-06 12:37:52 +02:00
ff73bafb0d #75 Feleslegesen nagy üres rész Jegyeknél fix 2025-10-06 12:37:40 +02:00
978df96aaf #78 Dolgozatnál hibás tördelés fix 2025-10-06 12:37:31 +02:00
07b8714c7e Amennyiben a felhasználónak nincsenek órái, azt a dizájn szerinti módon jelzi 2025-10-06 12:37:13 +02:00
8881f8c674 #77 Óra témája hibás tördelése fix 2025-10-06 12:37:06 +02:00
569147ae8a Tantárgyak feldolgozása 2025-09-29 21:44:15 +02:00
947e1d12cf Amennyiben nincs jegy a tantárgyból, ismeretlen a tanár is, tehát azt a szöveget kicseréltem 2025-09-29 21:43:59 +02:00
fd750ae6ad studyTask hozzáadása, SubjectAverage bővítése 2025-09-29 21:43:35 +02:00
f31b719605 SubjectAverage átírása, getLessons törlése 2025-09-29 21:43:05 +02:00
0bcdf35060 AllLessons törlése: nincs rá szükség 2025-09-29 21:43:17 +02:00
a0c1f3bb1f DKT API törlése, getSubjectAvg módosítása 2025-09-29 21:40:16 +02:00
6e35ca9d72 home/main: add homework entries 2025-09-30 09:54:51 +02:00
3b6e5d8213 add privacy policy button 2025-09-29 21:39:36 +02:00
894d897778 home,grades,tt: capitalize titles
some school administrators enter everything as lower case
2025-09-29 18:28:55 +02:00
a96be41d01 settings: add logout button 2025-09-29 12:47:15 +02:00
24876aebd6 lesson/sheet: add room name 2025-09-29 12:15:00 +02:00
e4aea80f0b android/gradle: Use /usr/bin/ as ANDROID_SDK_ROOT fallback 2025-09-27 20:56:10 +02:00
56cbaa6d16 message toast -> screen
the design has a screen for messages not a toast
2025-09-25 22:08:27 +02:00
850800864d adjust fonts 2025-09-25 21:47:23 +02:00
a92ea1dcf6 home/tt: Fix date formatting on the title bar 2025-09-25 21:33:20 +02:00
a9829b2163 model: translate field names to English 2025-09-25 21:30:52 +02:00
4f1c903384 Összes tantárgy megjelenítése 2025-09-25 15:57:47 +02:00
b045980bb4 lesson: fix popup height 2025-09-17 13:40:05 +02:00
352f9f223b fix: use proper font size 2025-09-17 13:32:46 +02:00
3d41113c0f fix(style): adjust font height 2025-09-17 13:32:24 +02:00
e616893ad2 debug: update throw exception
use a nested isar txn, which can trigger the error popup
2025-09-16 21:29:32 +02:00
8d934dd0f8 grade bottom sheet: fix size and padding 2025-09-16 21:25:59 +02:00
3e31804fe5 lesson: don't check the week if its empty 2025-09-16 21:23:56 +02:00
5f74a99f5a refactor: setting -> settings 2025-09-16 17:28:43 +02:00
6e16b9a37c tt: add haptic feedback to swiping between pages 2025-09-16 13:07:41 +02:00
ebcf49d957 home: fix styling in active lesson 2025-09-16 09:50:39 +02:00
aec2453a05 tt: fix last lesson getting covered by T H E S H A D O W 2025-09-16 09:40:11 +02:00
6c64ac4a35 added pfp for homescreen 2025-09-15 23:26:32 +02:00
ae06a61b05 fixes #59 2025-09-15 22:23:03 +02:00
d5c81d443e add haptic feedback 2025-09-15 21:36:52 +02:00
8b1b5cc4cf Update submodule 2025-09-15 19:09:15 +02:00
3d09187d6f home: remove logging 2025-09-15 19:02:14 +02:00
722068fdde Főoldalhoz hátramaradt tanórák száma fix 2025-09-15 18:51:22 +02:00
c471768598 Tanulo > Tanuló 2025-09-15 18:50:36 +02:00
2009418887 + jel kivétele: Nincs használva 2025-09-15 18:50:22 +02:00
94d3802e95 Több információ a beírt jegyeknél 2025-09-15 18:50:01 +02:00
4fe8af2e66 fix: use a 30 bit hash if the username is non-numeric 2025-09-15 15:09:29 +02:00
57122a4c3f Jenkins: update version code for aab 2025-09-15 12:43:18 +02:00
e5fac2609f Jenkins: move bundle compression to build_apk.sh 2025-09-15 12:25:49 +02:00
dd4ccf2736 wearos: fix pairing 2025-09-15 12:21:40 +02:00
Pearoo
1356fd3eb3 fix(timetable): clamp carousel to bottom
only tested on Pixel 9 Pro yet
2025-09-14 13:54:22 +02:00
Pearoo
fde6a47adc fix(timetable): no more unexplainable padding 2025-09-13 22:55:55 +02:00
Pearoo
c1edbe0971 feat(timetable): cache next & previous weeks 2025-09-13 20:14:04 +02:00
Pearoo
f04517749b chore: update cache retention 2025-09-13 20:13:09 +02:00
Pearoo
c36d656178 fix: next week resets to monday 2025-09-13 19:16:33 +02:00
8c4735ccb3 tt: fix event text color 2025-09-13 17:54:32 +02:00
a665478930 kreta_client: more verbose logging 2025-09-13 17:04:27 +02:00
033ab39c59 home_subpage: handle back events 2025-09-13 16:39:31 +02:00
6bf6b46119 move scaffold from grades & tt pages to subpage
fixes random white stuttering in the background
2025-09-13 14:14:27 +02:00
2e425dd757 tt: show breaks after last lesson is over 2025-09-13 14:07:25 +02:00
942ecc9db2 home/tt: truncate long lesson names and room names
closes #53
2025-09-13 13:59:33 +02:00
b4d87978e2 settings & home: use firka app bundle
fixes #61
2025-09-13 13:36:08 +02:00
e84ea8c383 home: only call setState on update if screen is mounted 2025-09-13 13:06:18 +02:00
5354c601eb settings: move postUpdate hook outside save function
fixes #62
2025-09-13 13:05:50 +02:00
9533fdaeec settings: add log file sharing 2025-09-13 12:05:04 +02:00
c79934f946 some apple things idk 2025-09-13 00:14:08 +02:00
d21b8955a7 removed support for ipad, macos, vision os 2025-09-12 23:15:26 +02:00
c95128a48e logging: censor debugPrint in release mode 2025-09-13 11:38:10 +02:00
308ff7f14c logging: delete old log files 2025-09-13 11:37:37 +02:00
69eae36ef8 logging: log to file 2025-09-13 11:32:39 +02:00
788f808d4e logger: censor sensitive information 2025-09-13 11:01:05 +02:00
c16c899e66 login: more verbose logging 2025-09-13 10:01:25 +02:00
6e2eeea8f0 use proper logging 2025-09-12 22:44:02 +02:00
191ee62be9 login: fix comparison 2025-09-12 18:44:28 +02:00
b51169ab3c token: add support for guardians 2025-09-12 18:12:54 +02:00
1902ace989 login: add a fallback error handler 2025-09-12 17:09:20 +02:00
d3e30689f0 firka_card: fix dark mode shadow
closes #39
2025-09-11 21:33:05 +02:00
c1aae7adb1 home: add bottom sheet for announcements 2025-09-11 19:34:01 +02:00
ee72056074 grade & home: add bottom sheet 2025-09-11 18:19:57 +02:00
4bd585d1dc grade: convert dates to local ones 2025-09-11 17:59:30 +02:00
4806ac073a refactor: extract showLessonBottomSheet out to a file 2025-09-11 17:25:12 +02:00
ac9a5cda91 home: add grades 2025-09-11 15:01:53 +02:00
60e0a1755b grades/{subjects}: fix text colors 2025-09-11 14:54:48 +02:00
9039356cfd fix color for light buttons in dark mode
closes #57
2025-09-11 14:16:59 +02:00
4327cae06a remove wear_login_screen.dart 2025-09-11 13:56:22 +02:00
c22d175afb home: check for mounting before preloading assets 2025-09-11 13:55:01 +02:00
6650b8aaef login: automatically switch to new account 2025-09-11 13:53:58 +02:00
e18f31a8b4 login: remove login hint from add account screen 2025-09-11 12:49:23 +02:00
caeca5e050 feat: account switching 2025-09-11 12:47:43 +02:00
e44684049f home: fix random bouncing
- switch pull_to_refresh_flutter3 from to smart_scroll
- decrease spring stiffness to 95

closes #47
2025-09-10 21:10:45 +02:00
359129c0dd tt: add animations to switching between pages
closes #45
2025-09-10 20:26:29 +02:00
e53df719c7 settings: add back button for rounding options 2025-09-10 10:38:10 +02:00
53c5ad4f14 tt: only show next break on current day
closes #50
2025-09-10 10:28:45 +02:00
dd59381b35 tt: increase padding when there is a test 2025-09-10 09:41:13 +02:00
5811ece08e tt: show tests on lessons when tests are hidden
closes #51
2025-09-10 07:48:57 +02:00
0701424b3c tt: show tests on day picker 2025-09-10 07:44:39 +02:00
93d3125013 settings: add padding to customization screen
closes #48
2025-09-09 16:47:38 +02:00
e96979d36f settings: add back button to developer options 2025-09-09 13:35:32 +02:00
9a17d9085c main: invalidate ui on platform brightness change 2025-09-09 13:27:42 +02:00
6c3ac6c09b settings: invalidate ui on theme & lang change 2025-09-09 13:26:16 +02:00
d1c34bd607 State -> FirkaState
Add a helper called FirkaState which registers
a listener to globalUpdate, so that we can
invalidate every widget that is active
2025-09-09 13:13:55 +02:00
869ba964ee date: use proper locale 2025-09-09 11:53:07 +02:00
4d45de372b login: add dark themed images 2025-09-09 11:33:36 +02:00
18adc04801 kreta_login: revert ua on iOS 2025-09-09 08:00:11 +02:00
c71dd3e680 revert: Change webview UA to Google Chrome on Android 2025-09-09 07:56:42 +02:00
e978459c19 kréta login update 2025-09-08 21:00:58 +02:00
5f7106d257 tt: fix lesson no for empty classes 2025-09-09 07:47:27 +02:00
1f2b8091f1 timetable: Fix lesson number icon
closes #49
2025-09-08 20:30:38 +02:00
932a89193e docs: Update CONTRIBUTING.md 2025-09-08 20:50:41 +02:00
048832589c Jenkinsfile: Bring back debug artifacts 2025-09-07 16:44:46 +02:00
f55ec7c525 tt: switch to monday if the week isn't the current one
closes #44
2025-09-07 16:43:37 +02:00
d3fce38235 tt: switch to monday if the week isn't the current one
closes #44
2025-09-07 16:30:28 +02:00
9183769ff4 tt: jump to current day
closes #42
2025-09-07 12:03:40 +02:00
4f4960280a tt: make padding more consistent 2025-09-07 11:26:05 +02:00
c85a428a7b fix: change "Hide breaks & empty lessons" to "Hide breaks"
closes #52
2025-09-07 11:22:22 +02:00
1738a38b4f home/grades/{subject}: fix text color 2025-09-07 11:17:49 +02:00
ddd7c5a9d6 tmp: remove lidl app icon
closes #46
2025-09-06 21:20:17 +02:00
a141f1822c fix SettingsItemsRadio
closes #41
2025-09-06 19:00:03 +02:00
41fab74f11 fix typo
closes #40
2025-09-06 18:47:58 +02:00
df1c5d9acf Jenkinsfile: build aab on release branch 2025-09-06 16:59:38 +02:00
a4b691f8be bump version string 2025-09-06 16:59:27 +02:00
ac82436751 settings: use runApp in lang settings as well 2025-09-06 10:46:44 +02:00
868d6f3665 fix: nav color when switching theme 2025-09-06 10:45:23 +02:00
bd965caa4c bottom_tt_icon.dart: add shadow to active icon 2025-09-06 10:28:48 +02:00
145eca557e android: build each abi in parallel 2025-09-06 08:54:55 +02:00
25e3ea95ba fix: reinit theme on platform brightness change 2025-09-06 08:47:35 +02:00
b6683158d9 bump version to 1.0.0+1017 2025-09-06 01:01:37 +02:00
3efc019322 update project configuration and framework references in Xcode project file 2025-09-04 21:42:00 +02:00
a980e4eff2 lesson: fix bottom sheet in dark mode 2025-09-06 08:24:23 +02:00
324d21ec07 tt: fix empty week in dark mode 2025-09-06 08:21:44 +02:00
1224d4801d fix: navbar theme on auto 2025-09-06 08:11:58 +02:00
51b8753875 extras: respect theming 2025-09-05 21:46:26 +02:00
b31e90b052 tt*: respect theming 2025-09-05 21:44:25 +02:00
2d5cc896b8 bottom_nav_icon.dart: respect theming 2025-09-05 21:41:10 +02:00
975c9c5a08 fix: native status bar theming 2025-09-05 21:38:10 +02:00
57d6c503f2 login: remove hardcoded colors 2025-09-05 21:28:07 +02:00
e3b71dbd4d dark mode toggle 2025-09-05 20:58:30 +02:00
260 changed files with 28699 additions and 2353 deletions

View File

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

View File

@@ -5,7 +5,7 @@ and to make a release build you will have to use our custom
flutter engine.
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

108
Jenkinsfile vendored
View File

@@ -16,6 +16,7 @@ pipeline {
}
}
}
stage('Decrypt main keys') {
when {
branch 'main'
@@ -40,6 +41,7 @@ pipeline {
'''
}
}
stage('Clone submodules') {
steps {
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') {
steps {
sh 'bash -c "./tools/linux/build_apk.sh ' + env.BRANCH_NAME + '"'
}
}
stage('Rename Release APKs') {
when {
branch 'main'
@@ -109,6 +88,7 @@ pipeline {
}
}
}
stage('Calculate Version Code') {
steps {
script {
@@ -118,7 +98,7 @@ pipeline {
# Calculate version code based on git commits (same logic as build script)
COMMIT_COUNT=$(git rev-list --count HEAD)
BASE_BUILD_NUMBER=$((1000 + COMMIT_COUNT))
BASE_BUILD_NUMBER=$((1300 + COMMIT_COUNT))
if [ "$BRANCH_NAME" = "main" ]; then
# 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 {
branch 'dev'
}
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"
'''
}
not {
branch 'main'
}
}
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 {
branch 'main'
}
steps {
script {
withCredentials([usernamePassword(credentialsId: 'fdroid-ssh', usernameVariable: 'SSH_USER', passwordVariable: 'SSHPASS')]) {
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"
'''
}
}
archiveArtifacts artifacts: 'firka/build/app/outputs/bundle/release/*.aab', fingerprint: true
sh 'rm firka/build/app/outputs/bundle/release/*.aab'
}
}
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') {
steps {
script {
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-debug.apk || true
rm version_code.txt || true

5
firka/.env.example Normal file
View 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
View File

@@ -12,6 +12,11 @@
.swiftpm/
migrate_working_dir/
# Environment variables
.env
.env.local
.env.*.local
# IntelliJ related
*.iml
*.ipr

View File

@@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
revision: "05db9689081f091050f01aed79f04dce0c750154"
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
channel: "stable"
project_type: app
@@ -13,11 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: 05db9689081f091050f01aed79f04dce0c750154
base_revision: 05db9689081f091050f01aed79f04dce0c750154
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
- platform: ios
create_revision: 05db9689081f091050f01aed79f04dce0c750154
base_revision: 05db9689081f091050f01aed79f04dce0c750154
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
# User provided section

View File

@@ -685,6 +685,11 @@ fun getDebugKeystorePath(): String {
fun getDefaultAndroidSdkPath(): String? {
val os = System.getProperty("os.name").lowercase()
val userHome = System.getProperty("user.home")
val zipAlign = File("/usr/bin/zipalign")
if (zipAlign.exists()) {
return "/usr/bin"
}
return when {
os.contains("win") ->
@@ -740,6 +745,11 @@ fun findToolInSdkPath(toolName: String): String? {
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")
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

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

View File

@@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

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

View File

@@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

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

View 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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

View File

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

View File

@@ -0,0 +1,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()
}

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

View File

@@ -0,0 +1,39 @@
import SwiftUI
struct GradeBadge: View {
let grade: Int
var size: CGFloat = 24
var color: Color {
switch grade {
case 5: return .green
case 4: return .blue
case 3: return .yellow
case 2: return .orange
default: return .red
}
}
var body: some View {
ZStack {
Circle()
.fill(color)
.frame(width: size, height: size)
Text("\(grade)")
.font(.system(size: size * 0.5, weight: .bold))
.foregroundColor(.white)
}
}
}
#Preview {
HStack(spacing: 12) {
GradeBadge(grade: 5)
GradeBadge(grade: 4)
GradeBadge(grade: 3)
GradeBadge(grade: 2)
GradeBadge(grade: 1)
}
.padding()
}

View File

@@ -0,0 +1,46 @@
import SwiftUI
struct GradeRow: View {
let grade: WidgetGrade
var body: some View {
HStack(alignment: .center, spacing: 8) {
Text(grade.displayValue)
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(.white)
.frame(width: 24, height: 24)
.background(
Circle()
.fill(grade.gradeColor)
)
VStack(alignment: .leading, spacing: 2) {
if let topic = grade.topic {
Text(topic)
.font(.caption2)
.foregroundColor(.primary)
.lineLimit(2)
}
HStack(spacing: 4) {
Text(grade.type.name)
.font(.system(size: 10))
.foregroundColor(.secondary)
if let weight = grade.weightPercentage, weight != 100 {
Text("(\(weight)%)")
.font(.system(size: 10))
.foregroundColor(.secondary)
}
}
}
Spacer()
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(Color(white: 0.15))
.cornerRadius(6)
}
}

View File

@@ -0,0 +1,146 @@
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()
}

View File

@@ -0,0 +1,68 @@
import SwiftUI
struct AverageProgressBar: View {
let average: Double
var progress: Double {
(average - 1) / 4
}
var color: Color {
switch average {
case 4.5...: return .green
case 3.5..<4.5: return .blue
case 2.5..<3.5: return .yellow
case 1.5..<2.5: return .orange
default: return .red
}
}
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2)
.fill(Color(white: 0.3))
RoundedRectangle(cornerRadius: 2)
.fill(color)
.frame(width: geo.size.width * progress)
}
}
.frame(height: 4)
}
}
#Preview {
VStack(spacing: 16) {
VStack(alignment: .leading) {
Text("5.0 - Excellent")
.font(.caption)
AverageProgressBar(average: 5.0)
}
VStack(alignment: .leading) {
Text("4.2 - Good")
.font(.caption)
AverageProgressBar(average: 4.2)
}
VStack(alignment: .leading) {
Text("3.0 - Average")
.font(.caption)
AverageProgressBar(average: 3.0)
}
VStack(alignment: .leading) {
Text("2.0 - Below Average")
.font(.caption)
AverageProgressBar(average: 2.0)
}
VStack(alignment: .leading) {
Text("1.2 - Poor")
.font(.caption)
AverageProgressBar(average: 1.2)
}
}
.padding()
}

View File

@@ -0,0 +1,43 @@
import SwiftUI
struct SubjectRow: View {
let name: String
let average: Double?
let gradeCount: Int
var averageColor: Color {
guard let avg = average else { return .gray }
switch avg {
case 4.5...: return .green
case 3.5...: return .blue
case 2.5...: return .yellow
case 1.5...: return .orange
default: return .red
}
}
var body: some View {
HStack(alignment: .center, spacing: 8) {
Text(name)
.font(.caption)
.foregroundColor(.primary)
Spacer()
if let avg = average {
Text(String(format: "%.2f", avg))
.font(.caption)
.fontWeight(.bold)
.foregroundColor(averageColor)
} else {
Text("\(gradeCount)")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(Color(white: 0.15))
.cornerRadius(6)
}
}

View File

@@ -0,0 +1,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()
}
}

View File

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

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

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

View File

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

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

View File

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

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

View File

@@ -0,0 +1,108 @@
import SwiftUI
struct GradesView: View {
let dataStore: DataStore
var body: some View {
NavigationStack {
if dataStore.data == nil {
ContentUnavailableView("no_data".localized, systemImage: "graduationcap")
} else if subjects.isEmpty {
ContentUnavailableView("no_grades".localized, systemImage: "graduationcap")
} else {
ScrollView {
VStack(spacing: 8) {
ForEach(subjects, id: \.uid) { subject in
NavigationLink {
GradeSubjectView(
subjectName: subject.name,
grades: gradesFor(subject.uid),
average: subject.average
)
} label: {
subjectRow(subject)
}
.buttonStyle(.plain)
}
if let overall = dataStore.data?.averages.overall {
overallAverageCard(overall)
}
}
.padding()
}
}
}
}
private var subjects: [SubjectAverage] {
(dataStore.data?.averages.subjects ?? []).sorted { $0.name < $1.name }
}
private func gradesFor(_ uid: String) -> [WidgetGrade] {
dataStore.data?.grades.filter { $0.subject.uid == uid } ?? []
}
@ViewBuilder
private func subjectRow(_ subject: SubjectAverage) -> some View {
FirkaCard {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(subject.name)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
Spacer()
Text(String(format: "%.2f", subject.average))
.font(.subheadline)
.fontWeight(.bold)
.foregroundColor(averageColor(subject.average))
}
HStack(spacing: 8) {
AverageProgressBar(average: subject.average)
Text("grades_count".localized(subject.gradeCount))
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
}
@ViewBuilder
private func overallAverageCard(_ average: Double) -> some View {
FirkaCard {
VStack(alignment: .leading, spacing: 4) {
Text("total_average".localized)
.font(.caption)
.foregroundColor(.secondary)
HStack {
Text(String(format: "%.2f", average))
.font(.title3)
.fontWeight(.bold)
.foregroundColor(averageColor(average))
Spacer()
AverageProgressBar(average: average)
.frame(width: 60)
}
}
}
.padding(.top, 8)
}
private func averageColor(_ avg: Double) -> Color {
switch avg {
case 4.5...: return .green
case 3.5..<4.5: return .blue
case 2.5..<3.5: return .yellow
case 1.5..<2.5: return .orange
default: return .red
}
}
}

View File

@@ -0,0 +1,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)
}
}
}

View File

@@ -0,0 +1,109 @@
import SwiftUI
struct LessonDetailView: View {
let lesson: WidgetLesson
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
HStack {
if let number = lesson.lessonNumber {
Text("lesson_number".localized(number))
.font(.caption)
.foregroundColor(.blue)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.2))
.cornerRadius(8)
}
Spacer()
Text("\(formatTime(lesson.start)) - \(formatTime(lesson.end))")
.font(.caption)
.foregroundColor(.secondary)
}
Text(lesson.displayName)
.font(.headline)
.lineLimit(3)
if lesson.isCancelled || lesson.isSubstitution {
HStack(spacing: 8) {
if lesson.isCancelled {
Label("cancelled".localized, systemImage: "xmark.circle.fill")
.font(.caption2)
.foregroundColor(.red)
}
if lesson.isSubstitution {
Label("substitution".localized, systemImage: "person.2.fill")
.font(.caption2)
.foregroundColor(.orange)
}
}
}
Divider()
VStack(alignment: .leading, spacing: 10) {
if lesson.isSubstitution, let substitute = lesson.substituteTeacher {
VStack(alignment: .leading, spacing: 4) {
Label("teacher".localized, systemImage: "person.fill")
.font(.caption)
.foregroundColor(.secondary)
if let original = lesson.teacher {
HStack(spacing: 4) {
Text(original)
.strikethrough()
.foregroundColor(.secondary)
Text("")
.foregroundColor(.orange)
Text(substitute)
.foregroundColor(.orange)
}
.font(.subheadline)
} else {
Text(substitute)
.font(.subheadline)
.foregroundColor(.orange)
}
}
} else if let teacher = lesson.teacher {
detailRow(icon: "person.fill", label: "teacher".localized, value: teacher)
}
if let room = lesson.roomName {
detailRow(icon: "door.right.hand.closed", label: "room".localized, value: room)
}
if let theme = lesson.theme, !theme.isEmpty {
detailRow(icon: "doc.text.fill", label: "topic".localized, value: theme)
}
}
}
.padding()
}
.navigationTitle("lesson_details".localized)
.navigationBarTitleDisplayMode(.inline)
}
@ViewBuilder
private func detailRow(icon: String, label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Label(label, systemImage: icon)
.font(.caption)
.foregroundColor(.secondary)
Text(value)
.font(.subheadline)
.lineLimit(5)
}
}
private func formatTime(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
return formatter.string(from: date)
}
}

View File

@@ -0,0 +1,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()
}

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,369 @@
#if os(watchOS)
import WidgetKit
import SwiftUI
// MARK: - Complication Localization Helper
private struct ComplicationL10n {
private static let appGroupID = "group.app.firka.firkaa"
enum Language: String {
case hungarian = "hu"
case english = "en"
case german = "de"
}
static var currentLanguage: Language {
guard let defaults = UserDefaults(suiteName: appGroupID) else {
return .hungarian
}
let code = defaults.string(forKey: "watch_language") ?? "hu"
return Language(rawValue: code) ?? .hungarian
}
static func string(_ key: String) -> String {
switch currentLanguage {
case .hungarian: return hungarianStrings[key] ?? key
case .english: return englishStrings[key] ?? key
case .german: return germanStrings[key] ?? key
}
}
private static let hungarianStrings: [String: String] = [
"current_lesson": "Jelenlegi óra",
"next": "Következő",
"no_more_lessons": "Nincs több óra",
"average_abbrev": "Átl",
"next_lesson_title": "Következő óra",
"average_title": "Átlag",
"lesson_inline": "Óra (inline)"
]
private static let englishStrings: [String: String] = [
"current_lesson": "Current Lesson",
"next": "Next",
"no_more_lessons": "No more lessons",
"average_abbrev": "Avg",
"next_lesson_title": "Next Lesson",
"average_title": "Average",
"lesson_inline": "Lesson (inline)"
]
private static let germanStrings: [String: String] = [
"current_lesson": "Aktuelle Stunde",
"next": "Nächste",
"no_more_lessons": "Keine Stunden mehr",
"average_abbrev": "Ø",
"next_lesson_title": "Nächste Stunde",
"average_title": "Durchschnitt",
"lesson_inline": "Stunde (inline)"
]
}
// MARK: - Watch Cache Loader
private struct WatchCacheLoader {
private static let appGroupID = "group.app.firka.firkaa"
private static let cacheFileName = "watch_data.json"
static func loadWidgetData() -> WidgetData? {
guard let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: appGroupID
) else {
print("[WatchComplication] No App Group container")
return nil
}
let fileURL = containerURL.appendingPathComponent(cacheFileName)
guard FileManager.default.fileExists(atPath: fileURL.path) else {
print("[WatchComplication] Cache file not found: \(fileURL.path)")
return nil
}
guard let data = try? Data(contentsOf: fileURL) else {
print("[WatchComplication] Could not read cache file")
return nil
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
struct CachedWatchData: Codable {
let widgetData: WidgetData
let lastUpdated: Date
}
do {
let cached = try decoder.decode(CachedWatchData.self, from: data)
print("[WatchComplication] Loaded cache from \(cached.lastUpdated)")
return cached.widgetData
} catch {
print("[WatchComplication] Failed to decode: \(error)")
return nil
}
}
}
// MARK: - Timeline Entry
struct FirkaTimelineEntry: TimelineEntry {
let date: Date
let data: WidgetData?
}
// MARK: - Timeline Provider
struct FirkaTimelineProvider: TimelineProvider {
func placeholder(in context: Context) -> FirkaTimelineEntry {
FirkaTimelineEntry(date: Date(), data: nil)
}
func getSnapshot(in context: Context, completion: @escaping (FirkaTimelineEntry) -> Void) {
let data = WatchCacheLoader.loadWidgetData()
completion(FirkaTimelineEntry(date: Date(), data: data))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<FirkaTimelineEntry>) -> Void) {
let data = WatchCacheLoader.loadWidgetData()
let entry = FirkaTimelineEntry(date: Date(), data: data)
let calendar = Calendar.current
let now = Date()
let hour = calendar.component(.hour, from: now)
let weekday = calendar.component(.weekday, from: now)
let isSchoolHours = (weekday >= 2 && weekday <= 6) && (hour >= 6 && hour <= 16)
let refreshInterval: TimeInterval = isSchoolHours ? 15 * 60 : 60 * 60
let nextRefresh = now.addingTimeInterval(refreshInterval)
let timeline = Timeline(entries: [entry], policy: .after(nextRefresh))
completion(timeline)
}
}
// MARK: - Next Lesson Complication (accessoryRectangular)
struct NextLessonComplication: Widget {
let kind = "NextLessonComplication"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: FirkaTimelineProvider()) { entry in
NextLessonView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName(ComplicationL10n.string("next_lesson_title"))
.description("Shows the current or next lesson.")
.supportedFamilies([.accessoryRectangular])
}
}
private struct NextLessonView: View {
let entry: FirkaTimelineEntry
private var now: Date { Date() }
private var todayLessons: [WidgetLesson] {
(entry.data?.timetable.today ?? []).sorted { $0.start < $1.start }
}
private var currentLesson: WidgetLesson? {
todayLessons.first { now >= $0.start && now <= $0.end }
}
private var nextLesson: WidgetLesson? {
todayLessons.first { $0.start > now }
}
var body: some View {
if let breakInfo = entry.data?.timetable.currentBreak {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: SeasonalIconHelper.iconName(for: breakInfo.nameKey, season: nil))
.font(.caption)
Text(breakInfo.name)
.font(.headline)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
} else if let lesson = currentLesson {
VStack(alignment: .leading, spacing: 2) {
Text(ComplicationL10n.string("current_lesson"))
.font(.caption2)
.foregroundStyle(.secondary)
Text(lesson.displayName)
.font(.headline)
.lineLimit(1)
HStack(spacing: 4) {
if let room = lesson.roomName {
Image(systemName: "door.right.hand.closed")
.font(.caption2)
Text(room)
.font(.caption2)
}
Text("\(lesson.end, formatter: Self.timeFormatter)")
.font(.caption2)
}
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else if let lesson = nextLesson {
VStack(alignment: .leading, spacing: 2) {
Text(ComplicationL10n.string("next"))
.font(.caption2)
.foregroundStyle(.secondary)
Text(lesson.displayName)
.font(.headline)
.lineLimit(1)
HStack(spacing: 4) {
if let room = lesson.roomName {
Image(systemName: "door.right.hand.closed")
.font(.caption2)
Text(room)
.font(.caption2)
}
Text(lesson.start, formatter: Self.timeFormatter)
.font(.caption2)
}
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else if entry.data != nil {
VStack(alignment: .leading, spacing: 2) {
Image(systemName: "checkmark.circle.fill")
.font(.title3)
.foregroundStyle(.green)
Text(ComplicationL10n.string("no_more_lessons"))
.font(.headline)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
VStack(alignment: .leading, spacing: 2) {
Image(systemName: "book.fill")
.font(.title3)
Text("Firka")
.font(.headline)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private static let timeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm"
return f
}()
}
// MARK: - Average Complication (accessoryCircular)
struct AverageComplication: Widget {
let kind = "AverageComplication"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: FirkaTimelineProvider()) { entry in
AverageView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName(ComplicationL10n.string("average_title"))
.description("Shows the overall grade average.")
.supportedFamilies([.accessoryCircular])
}
}
private struct AverageView: View {
let entry: FirkaTimelineEntry
private var averageColor: Color {
guard let avg = entry.data?.averages.overall else { return .gray }
switch avg {
case 4.5...: return .green
case 3.5..<4.5: return .blue
case 2.5..<3.5: return .yellow
case 1.5..<2.5: return .orange
default: return .red
}
}
var body: some View {
if let average = entry.data?.averages.overall {
Gauge(value: average, in: 1...5) {
Text(ComplicationL10n.string("average_abbrev"))
} currentValueLabel: {
Text(String(format: "%.1f", average))
.font(.system(.body, design: .rounded, weight: .bold))
}
.gaugeStyle(.accessoryCircular)
.tint(averageColor)
} else {
ZStack {
AccessoryWidgetBackground()
Text("")
.font(.title3)
.fontWeight(.bold)
}
}
}
}
// MARK: - Inline Complication (accessoryInline)
struct InlineComplication: Widget {
let kind = "InlineComplication"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: FirkaTimelineProvider()) { entry in
InlineView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName(ComplicationL10n.string("lesson_inline"))
.description("One-line summary of the next lesson.")
.supportedFamilies([.accessoryInline])
}
}
private struct InlineView: View {
let entry: FirkaTimelineEntry
private var now: Date { Date() }
private var todayLessons: [WidgetLesson] {
(entry.data?.timetable.today ?? []).sorted { $0.start < $1.start }
}
private var currentOrNextLesson: WidgetLesson? {
todayLessons.first { now >= $0.start && now <= $0.end }
?? todayLessons.first { $0.start > now }
}
var body: some View {
if let breakInfo = entry.data?.timetable.currentBreak {
Text(breakInfo.name)
} else if let lesson = currentOrNextLesson {
Text("\(lesson.displayName) \(lesson.start, formatter: Self.timeFormatter)")
} else if entry.data != nil {
Text(ComplicationL10n.string("no_more_lessons"))
} else {
Text("Firka")
}
}
private static let timeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm"
return f
}()
}
// MARK: - Widget Bundle
@main
struct FirkaWatchWidgets: WidgetBundle {
var body: some Widget {
NextLessonComplication()
AverageComplication()
InlineComplication()
}
}
#endif

View File

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

View File

@@ -0,0 +1,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>

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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"))
}
}

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

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

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

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

View File

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

View File

@@ -0,0 +1,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]?
}

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

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

View File

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

View File

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

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

Some files were not shown because too many files have changed in this diff Show More