54 Commits
dev ... old

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
Horváth Gergely
626d6aefdd Added an example of the .env file. 2025-11-19 13:36:35 +01:00
263 changed files with 20817 additions and 15141 deletions

15
.gitmodules vendored
View File

@@ -1,9 +1,18 @@
[submodule "firka/vendor/isar_generator"]
path = firka/vendor/isar_generator
url = https://git.qwit.cloud/firka/isar_generator
[submodule "firka/vendor/isar"]
path = firka/vendor/isar
url = https://git.qwit.cloud/firka/isar
[submodule "firka/vendor/isar_flutter_libs"]
path = firka/vendor/isar_flutter_libs
url = https://git.qwit.cloud/firka/isar_flutter_libs
[submodule "firka/lib/l10n"] [submodule "firka/lib/l10n"]
path = firka/lib/l10n path = firka/lib/l10n
url = https://github.com/QwIT-Development/firka-localization url = https://github.com/QwIT-Development/firka-localization
[submodule "firka_wear/vendor/wear_plus"]
path = firka_wear/vendor/wear_plus
url = https://git.firka.app/firka/wear_plus
[submodule "firka/android/app/src/main/java/org/brotli"] [submodule "firka/android/app/src/main/java/org/brotli"]
path = firka/android/app/src/main/java/org/brotli path = firka/android/app/src/main/java/org/brotli
url = https://git.firka.app/firka/org_brotli url = https://git.firka.app/firka/org_brotli
[submodule "firka_wear/lib/l10n"]
path = firka_wear/lib/l10n
url = https://github.com/qwit-development/firka-localization

5
firka/.gitignore vendored
View File

@@ -49,7 +49,4 @@ app.*.map.json
/android/app/profile /android/app/profile
/android/app/release /android/app/release
coverage coverage
# Generated files
*.g.dart

View File

@@ -1,18 +1,36 @@
import org.apache.commons.io.FileUtils
import java.io.FileInputStream import java.io.FileInputStream
import java.security.MessageDigest
import java.util.Properties import java.util.Properties
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.locks.ReentrantReadWriteLock
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream
import java.util.zip.ZipOutputStream.DEFLATED
import java.util.zip.ZipOutputStream.STORED
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.compose") version "2.2.0"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
fun loadProperties(file: File): Properties {
val properties = Properties()
FileInputStream(file).use { inputStream ->
properties.load(inputStream)
}
return properties
}
android { android {
namespace = "app.firka.naplo" namespace = "app.firka.naplo"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = "27.0.12077973"
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
@@ -31,8 +49,8 @@ android {
applicationId = "app.firka.naplo" applicationId = "app.firka.naplo"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = 29
targetSdk = flutter.targetSdkVersion targetSdk = 36
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
} }
@@ -41,7 +59,7 @@ android {
val propsFile = File(secretsDir, "keystore.properties") val propsFile = File(secretsDir, "keystore.properties")
if (propsFile.exists()) { if (propsFile.exists()) {
val props = Properties().apply { FileInputStream(propsFile).use { load(it) } } val props = loadProperties(propsFile)
val store = File(secretsDir, props["storeFile"].toString()) val store = File(secretsDir, props["storeFile"].toString())
signingConfigs { signingConfigs {
@@ -50,10 +68,6 @@ android {
storePassword = props["storePassword"] as String storePassword = props["storePassword"] as String
keyPassword = props["keyPassword"] as String keyPassword = props["keyPassword"] as String
keyAlias = props["keyAlias"] as String keyAlias = props["keyAlias"] as String
// Use APK Signature Scheme v3 (and v4 for streaming verification). See:
// https://source.android.com/docs/security/features/apksigning/v3
enableV3Signing = true
enableV4Signing = true
} }
} }
} }
@@ -75,25 +89,808 @@ android {
} }
} }
dependencies { dependencies {
implementation("androidx.wear:wear-ongoing:1.0.0")
implementation("androidx.glance:glance-appwidget:1.1.1") implementation("androidx.glance:glance-appwidget:1.1.1")
implementation("com.google.android.gms:play-services-wearable:18.1.0")
}
// Ensure .env exists before Flutter bundles assets (copy from .env.example if missing)
val envFile = file("${project.projectDir}/../../.env")
val envExampleFile = file("${project.projectDir}/../../.env.example")
tasks.register("ensureEnv") {
doLast {
if (!envFile.exists() && envExampleFile.exists()) {
envExampleFile.copyTo(envFile, overwrite = false)
println("Created .env from .env.example for asset bundling.")
}
}
}
tasks.matching { it.name.startsWith("compileFlutterBuild") }.configureEach {
dependsOn("ensureEnv")
} }
flutter { flutter {
source = "../.." source = "../.."
} }
tasks.register("transformAndResignDebugApk") {
group = "build"
description = "Transform and resign APK with debug key"
dependsOn("assembleDebug")
doLast {
if (System.getenv("TRANSFORM_APK") != null
&& System.getenv("TRANSFORM_APK") == "true") {
transformApks(true)
}
}
}
tasks.register("transformAndResignReleaseApk") {
group = "build"
description = "Transform and resign APK with release key"
dependsOn("assembleRelease")
doLast {
checkReleaseKey()
if (System.getenv("TRANSFORM_APK") != null
&& System.getenv("TRANSFORM_APK") == "true") {
transformApks(false)
}
}
}
tasks.register("transformAndResignReleaseBundle") {
group = "build"
description = "Transform and resign bundle with release key"
dependsOn("bundleRelease")
doLast {
if (System.getenv("TRANSFORM_AAB") != null
&& System.getenv("TRANSFORM_AAB") == "true") {
transformAppBundle()
}
}
}
afterEvaluate {
tasks.findByName("assembleDebug")?.finalizedBy("transformAndResignDebugApk")
tasks.findByName("assembleRelease")?.finalizedBy("transformAndResignReleaseApk")
tasks.findByName("bundleRelease")?.finalizedBy("transformAndResignReleaseBundle")
}
fun checkReleaseKey() {
val secretsDir = File(projectDir.absolutePath, "../../../secrets/")
val propsFile = File(secretsDir, "keystore.properties")
if (propsFile.exists()) {
val props = loadProperties(propsFile)
val store = File(secretsDir, props["storeFile"].toString())
println(
"Signing with:\n" +
"\t- store: ${store.name}\n" +
"\t- key: ${props["keyAlias"]}"
)
} else {
throw Exception("Release keystore not found!")
}
}
fun transformApks(debug: Boolean, i : Int = 0) {
try {
_transformApks(debug)
} catch (e: Exception) {
if (i < 5) {
e.printStackTrace()
println("Retrying: ${i + 1}")
transformApks(debug, i + 1)
} else {
throw e
}
}
}
fun _transformApks(debug: Boolean) {
println("Starting APK transformation process...")
val buildDir = project.buildDir
val apkDir = File(buildDir, "outputs/flutter-apk")
val apks = getApks(debug)
var c = 0
apks
.forEach { c++; transformAndSignApk(apkDir, it.nameWithoutExtension, debug) }
println("Transformed: $c apks")
}
fun transformAndSignApk(apkDir: File, name: String, debug: Boolean) {
val originalApk = File(apkDir, "$name.apk")
val transformedApk = File(apkDir, "$name-transformed.apk")
val finalApk = File(apkDir, "$name-resigned.apk")
val finalIdsig = File(apkDir, "$name-resigned.apk.idsig")
if (!originalApk.exists()) {
throw GradleException("Original APK not found at: ${originalApk.absolutePath}")
}
if (transformedApk.exists()) transformedApk.delete()
if (finalApk.exists()) finalApk.delete()
println("Original APK: ${originalApk.absolutePath}")
try {
println("Transforming APK...")
transformApk(originalApk, transformedApk, if (debug) { "6" } else {"Z"})
if (debug) {
println("Signing with debug key...")
signWithDebugKey(transformedApk, finalApk)
} else {
println("Signing with release key...")
signWithReleaseKey(transformedApk, finalApk)
}
if (finalApk.exists()) {
originalApk.delete()
finalIdsig.delete()
finalApk.renameTo(originalApk)
println("APK successfully transformed")
println("Final APK: ${originalApk.absolutePath}")
}
transformedApk.delete()
} catch (e: Exception) {
throw GradleException("Failed to transform and resign APK: ${e.message}", e)
}
}
fun transformApk(input: File, output: File, compressionLevel: String = "Z") {
val tempDir = File(project.buildDir, "tmp/apk-transform")
val cacheDir = File(project.buildDir, "cache")
val optipngCacheDir = File(cacheDir, "optipng")
val assetCompressionDir = File(cacheDir, "assets")
tempDir.deleteRecursively()
tempDir.mkdirs()
if (!optipngCacheDir.exists()) optipngCacheDir.mkdirs()
if (!assetCompressionDir.exists()) assetCompressionDir.mkdirs()
val brotli = findToolInPath("brotli")
?: throw Exception("Brotli not found in path")
val optipng = findToolInPath("optipng")
if (optipng == null || optipng.isEmpty()) {
println("Optipng was not found in PATH, optimizing images will be skipped.")
}
copy {
from(zipTree(input))
into(tempDir)
}
val metaInf = File(tempDir, "META-INF")
val metaInfFiles = metaInf.listFiles()
for (file in metaInfFiles!!) {
if (file.name.endsWith("MF") || file.name.endsWith("SF")
|| file.name.endsWith("RSA")) {
file.delete()
}
}
val arches = File(tempDir, "lib").listFiles()
val compressedLibs = mutableMapOf<String, String>()
for (arch in arches!!) {
val libFlutter = File(arch, "libflutter.so")
if (!libFlutter.exists()) continue
val compressedFlutter = File(arch, "libflutter-br.so")
compressedLibs["libflutter.so"] = libFlutter.sha256()
println("Compressing ${arch.name}/libflutter.so with brotli")
exec {
commandLine(
brotli,
"-$compressionLevel",
libFlutter.absolutePath,
"-o", compressedFlutter.absolutePath
)
}
libFlutter.delete()
val json = groovy.json.JsonBuilder(compressedLibs)
File(arch, "index.so").writeText(json.toString())
}
val topDirL = tempDir.absolutePath.length + 1
val zos = ZipOutputStream(output.outputStream())
val coreCount = Runtime.getRuntime().availableProcessors()
val flutterResources = tempDir.walkTopDown().filter{f -> f.absolutePath.contains("flutter_assets")}
val pngFiles = tempDir.walkTopDown().filter{f -> f.name.endsWith(".png")}
val assetIndex = mutableMapOf<String, String>()
val indexReadWriteLock = ReentrantReadWriteLock()
if (compressionLevel == "Z") {
if (optipng != null) {
val executor = Executors.newFixedThreadPool(coreCount)
val futures = mutableListOf<Future<*>>()
pngFiles.forEach { pngFile ->
val cacheFile = File(optipngCacheDir, pngFile.sha256())
if (cacheFile.exists()) {
cacheFile.copyTo(pngFile, true)
} else {
val future = executor.submit {
exec {
commandLine(
optipng,
"-zm", "9",
"-zw", "32k",
"-o9",
pngFile.absolutePath
)
}
pngFile.copyTo(cacheFile, true)
}
futures.add(future)
}
}
futures.forEach { it.get() }
executor.shutdown()
}
val executor = Executors.newFixedThreadPool(coreCount)
val futures = mutableListOf<Future<*>>()
val blacklist = listOf(
// "AssetManifest.bin",
"AssetManifest.json",
"FontManifest.json",
"isolate_snapshot_data",
"kernel_blob.bin",
"NativeAssetsManifest.json",
"NOTICES.Z",
"vm_snapshot_data",
"fonts",
"shaders"
)
flutterResources.forEach { f ->
val relName = f.absolutePath.substring(topDirL).replace("\\", "/")
if (f.isDirectory) return@forEach
val cacheFileRaw = File(assetCompressionDir, f.sha256()+".r")
val cacheFileGz = File(assetCompressionDir, f.sha256()+".gz")
val cacheFileBr = File(assetCompressionDir, f.sha256()+".br")
if (cacheFileRaw.exists() || cacheFileGz.exists() || cacheFileBr.exists()) {
if (cacheFileRaw.exists()) {
cacheFileRaw.copyTo(f, true)
indexReadWriteLock.writeLock().lock()
assetIndex[relName] = "r"
indexReadWriteLock.writeLock().unlock()
} else if (cacheFileGz.exists()) {
cacheFileGz.copyTo(f, true)
indexReadWriteLock.writeLock().lock()
assetIndex[relName] = "g"
indexReadWriteLock.writeLock().unlock()
} else {
cacheFileBr.copyTo(f, true)
indexReadWriteLock.writeLock().lock()
assetIndex[relName] = "b"
indexReadWriteLock.writeLock().unlock()
}
} else {
val future = executor.submit {
val brTmp = File(f.absolutePath + ".br.tmp")
val gzTmp = File(f.absolutePath + ".gz.tmp")
var blacklisted = false
for (f in blacklist) {
if (relName.contains(f)) {
blacklisted = true
break
}
}
if (!blacklisted) {
println("$relName: Testing with brotli")
exec {
commandLine(
brotli,
"-$compressionLevel",
f.absolutePath,
"-o", brTmp.absolutePath
)
}
println("$relName: Testing with gzip")
ant.invokeMethod(
"gzip", mapOf(
"src" to f.absolutePath,
"destfile" to gzTmp.absolutePath,
)
)
println("$brTmp: ${brTmp.length()}")
println("$gzTmp: ${gzTmp.length()}")
if (f.length() < gzTmp.length() && f.length() < brTmp.length()) {
println("$relName: Raw file wins")
f.copyTo(cacheFileRaw, true)
indexReadWriteLock.writeLock().lock()
assetIndex[relName] = "r"
indexReadWriteLock.writeLock().unlock()
} else {
if (brTmp.length() < gzTmp.length()) {
println("$relName: Brotli wins")
f.delete()
brTmp.copyTo(f, true)
brTmp.copyTo(cacheFileBr, true)
indexReadWriteLock.writeLock().lock()
assetIndex[relName] = "b"
indexReadWriteLock.writeLock().unlock()
} else {
println("$relName: Gzip wins")
f.delete()
gzTmp.copyTo(f, true)
gzTmp.copyTo(cacheFileGz, true)
indexReadWriteLock.writeLock().lock()
assetIndex[relName] = "g"
indexReadWriteLock.writeLock().unlock()
}
}
brTmp.delete()
gzTmp.delete()
}
}
futures.add(future)
}
}
futures.forEach { it.get() }
executor.shutdown()
}
tempDir.walkTopDown().forEach { f ->
if (f.absolutePath == tempDir.absolutePath) return@forEach
var relName = f.absolutePath.substring(topDirL).replace("\\", "/")
if (f.isDirectory && !relName.endsWith("/")) relName += "/"
if (compressionLevel == "Z") {
if (relName == "assets/flutter_assets/assets/firka.i") return@forEach
}
println(relName)
val compress = !relName.endsWith(".so") && !relName.endsWith(".arsc")
zos.setMethod(if (compress) { DEFLATED } else { STORED })
val entry = ZipEntry(relName)
if (!compress) {
entry.size = f.length()
entry.crc = FileUtils.checksumCRC32(f)
}
zos.putNextEntry(entry)
if (f.isFile) {
zos.write(f.readBytes())
}
zos.closeEntry()
}
if (compressionLevel == "Z") {
zos.setMethod(DEFLATED)
zos.putNextEntry(ZipEntry("assets/flutter_assets/assets/firka.i"))
val indexUncompressed = File(tempDir, "index.json")
indexReadWriteLock.readLock().lock()
val json = groovy.json.JsonBuilder(assetIndex)
indexReadWriteLock.readLock().unlock()
indexUncompressed.writeText(json.toString())
val indexCompressed = File(tempDir, "index.json.br")
exec {
commandLine(
brotli,
"-$compressionLevel",
indexUncompressed.absolutePath,
"-o", indexCompressed.absolutePath
)
}
zos.write(indexCompressed.readBytes())
indexUncompressed.delete()
indexCompressed.delete()
zos.closeEntry()
}
zos.close()
tempDir.deleteRecursively()
println("APK transformed successfully")
}
fun transformAppBundle() {
val buildDir = project.buildDir
val bundle = File(buildDir, "outputs/bundle/release/app-release.aab")
val bundleTmp = File(buildDir, "outputs/bundle/release/tmp.zip")
val apks = getApks(false)
val apkCount = apks.count { it.name.startsWith("app-") && it.name.endsWith("-release.apk") }
if (!bundle.exists()) {
throw Exception("Bundle not found at: $bundle")
}
if (apkCount < 3) {
throw Exception("Excepected 3 apks per abi but only found $apkCount")
}
val aabTempDir = File(project.buildDir, "tmp/aab-transform")
aabTempDir.deleteRecursively()
aabTempDir.mkdirs()
val apksUnzipped = File(project.buildDir, "tmp/apks-unzipped")
apksUnzipped.deleteRecursively()
val arm32TempDir = File(apksUnzipped, "armeabi-v7a")
arm32TempDir.mkdirs()
val arm64TempDir = File(apksUnzipped, "arm64-v8a")
arm64TempDir.mkdirs()
val x86TempDir = File(apksUnzipped, "x86_64")
x86TempDir.mkdirs()
copy {
from(zipTree(bundle))
into(aabTempDir)
}
copy {
from(zipTree(apks.first { it.name.contains("armeabi-v7a") }))
into(arm32TempDir)
}
copy {
from(zipTree(apks.first { it.name.contains("arm64-v8a") }))
into(arm64TempDir)
}
copy {
from(zipTree(apks.first { it.name.contains("x86_64") }))
into(x86TempDir)
}
val libs = File(aabTempDir, "base/lib").listFiles()!!
for (dstLibs in libs) {
println("Copying lib: ${dstLibs.name}")
val srcDir = File(apksUnzipped, dstLibs.name)
if (!srcDir.exists()) {
continue
}
val srcLibs = File(srcDir, "lib/${dstLibs.name}/")
dstLibs.listFiles()!!.forEach { it.delete() }
srcLibs.listFiles()!!.forEach { it.copyTo(File(dstLibs, it.name)) }
}
val zos = ZipOutputStream(bundleTmp.outputStream())
val bundleZip = ZipFile(bundle)
val bundleEntries = bundleZip.entries()
val brotli = findToolInPath("brotli")
?: throw Exception("Brotli not found in path")
val optipng = findToolInPath("optipng")
?: throw Exception("Optipng not found in path")
val indexReadWriteLock = ReentrantReadWriteLock()
val assetIndex = mutableMapOf<String, String>()
while (bundleEntries.hasMoreElements()) {
val entry = bundleEntries.nextElement()
/*
if (entry.name == "base/assets/flutter_assets/assets/firka.i") {
println("Patching: ${entry.name}")
zos.putNextEntry(ZipEntry("assets/flutter_assets/assets/firka.i"))
val indexUncompressed = File(aabTempDir, "index.json")
indexReadWriteLock.readLock().lock()
val json = groovy.json.JsonBuilder(assetIndex)
indexReadWriteLock.readLock().unlock()
indexUncompressed.writeText(json.toString())
val indexCompressed = File(aabTempDir, "index.json.br")
exec {
commandLine(
brotli,
"-Z",
indexUncompressed.absolutePath,
"-o", indexCompressed.absolutePath
)
}
zos.write(indexCompressed.readBytes())
indexUncompressed.delete()
indexCompressed.delete()
zos.closeEntry()
continue
}
if (entry.name.startsWith("base/lib")) {
println("Patching: ${entry.name}")
zos.putNextEntry(ZipEntry(entry.name))
zos.closeEntry()
continue
}
*/
println("Adding: ${entry.name}")
zos.putNextEntry(ZipEntry(entry.name))
if (!entry.isDirectory) {
val data = bundleZip.getInputStream(entry).readAllBytes()
zos.write(data)
}
zos.closeEntry()
}
bundleZip.close()
zos.close()
bundle.delete()
signBundle(bundleTmp, bundle)
bundleTmp.delete()
aabTempDir.deleteRecursively()
println("AAB transformed successfully")
}
fun File.sha256(): String {
val md = MessageDigest.getInstance("SHA-256")
val digest = md.digest(this.readBytes())
return digest.fold("") { str, it -> str + "%02x".format(it) }
}
fun getApks(debug: Boolean): List<File> {
val buildDir = project.buildDir
val apkDir = File(buildDir, "outputs/flutter-apk")
val apks = apkDir.listFiles()!!
val flavor = if (debug) { "debug" } else { "release" }
return apks
.filter { apk -> apk.name.startsWith("app-") && apk.name.endsWith("-$flavor.apk") }
.toList()
}
fun getDebugKeystorePath(): String {
val userHome = System.getProperty("user.home")
val debugKeystore = File(userHome, ".android/debug.keystore")
if (!debugKeystore.exists()) {
throw GradleException("Debug keystore not found at: ${debugKeystore.absolutePath}")
}
return debugKeystore.absolutePath
}
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") ->
"$userHome\\AppData\\Local\\Android\\Sdk"
os.contains("mac") ->
"$userHome/Library/Android/sdk"
os.contains("linux") ->
"$userHome/Android/Sdk"
else -> null
}
}
fun findToolInPath(toolName: String): String? {
val pathEnvironment = System.getenv("PATH")
val pathDirs = pathEnvironment.split(File.pathSeparator)
val executableNames = when {
System.getProperty("os.name").lowercase().contains("win") ->
listOf("$toolName.exe", toolName)
else ->
listOf(toolName)
}
for (pathDir in pathDirs) {
for (execName in executableNames) {
val possibleTool = File(pathDir, execName)
if (possibleTool.exists() && possibleTool.canExecute()) {
return possibleTool.absolutePath
}
}
}
return null
}
fun findToolInSdkPath(toolName: String): String? {
var androidHome : String? = System.getenv("ANDROID_HOME")
?: System.getenv("ANDROID_SDK_ROOT")
if (androidHome == null) androidHome = getDefaultAndroidSdkPath()
if (androidHome != null) {
val buildTools = File(androidHome, "build-tools")
if (buildTools.exists()) {
val latestVersion = buildTools.listFiles()
?.filter { it.isDirectory }
?.filter { it.name != "debian" }
?.maxByOrNull { it.name }
if (latestVersion != null) {
val toolExec = File(latestVersion, toolName)
if (toolExec.exists()) {
return toolExec.absolutePath
}
}
} else {
val toolExec = File(androidHome, toolName)
if (toolExec.exists()) {
return toolExec.absolutePath
}
}
}
if (!toolName.contains(".exe")) {
val exeTool = findToolInSdkPath("$toolName.exe")
if (exeTool != null) return exeTool
}
if (!toolName.contains(".sh")) {
val shTool = findToolInSdkPath("$toolName.sh")
if (shTool != null) return shTool
}
if (!toolName.contains(".bat")) {
val batTool = findToolInSdkPath("$toolName.bat")
if (batTool != null) return batTool
}
return null
}
fun signWithDebugKey(input: File, output: File) {
val debugKeystore = getDebugKeystorePath()
val debugKeystorePassword = "android"
val debugKeyAlias = "androiddebugkey"
val debugKeyPassword = "android"
val zipAlign: String = findToolInSdkPath("zipalign")
?: throw Exception("Could not find zipalign in ANDROID_SDK")
val apksigner: String = findToolInSdkPath("apksigner")
?: throw Exception("Could not find zipalign in ANDROID_SDK")
exec {
commandLine(
zipAlign,
"-v", "4",
input.absolutePath,
output.absolutePath
)
}
exec {
commandLine(
apksigner, "sign",
"--ks", debugKeystore,
"--ks-pass", "pass:$debugKeystorePassword",
"--ks-key-alias", debugKeyAlias,
"--key-pass", "pass:$debugKeyPassword",
output.absolutePath
)
}
println("APK signed and aligned successfully")
}
fun signWithReleaseKey(input: File, output: File) {
val secretsDir = File(projectDir.absolutePath, "../../../secrets/")
val propsFile = File(secretsDir, "keystore.properties")
if (!propsFile.exists()) {
throw Exception("Release keystore not found!")
}
val props = loadProperties(propsFile)
val releaseKeystore = File(secretsDir, props["storeFile"].toString())
val releaseKeystorePassword = props["storePassword"] as String
val releaseKeyAlias = props["keyAlias"] as String
val releaseKeyPassword = props["keyPassword"] as String
val zipAlign: String = findToolInSdkPath("zipalign")
?: throw Exception("Could not find zipalign either in ANDROID_SDK")
val apksigner: String = findToolInSdkPath("apksigner")
?: throw Exception("Could not find zipalign either in ANDROID_SDK")
exec {
commandLine(
zipAlign,
"-v", "4",
input.absolutePath,
output.absolutePath
)
}
exec {
commandLine(
apksigner, "sign",
"--ks", releaseKeystore,
"--ks-pass", "pass:$releaseKeystorePassword",
"--ks-key-alias", releaseKeyAlias,
"--key-pass", "pass:$releaseKeyPassword",
output.absolutePath
)
}
println("APK signed and aligned successfully")
}
fun signBundle(input: File, output: File) {
val secretsDir = File(projectDir.absolutePath, "../../../secrets/")
val propsFile = File(secretsDir, "keystore.properties")
if (!propsFile.exists()) {
throw Exception("Release keystore not found!")
}
val props = loadProperties(propsFile)
val releaseKeystore = File(secretsDir, props["storeFile"].toString())
val releaseKeystorePassword = props["storePassword"] as String
val releaseKeyAlias = props["keyAlias"] as String
val releaseKeyPassword = props["keyPassword"] as String
// val zipAlign: String = findToolInSdkPath("zipalign")
// ?: throw Exception("Could not find zipalign in ANDROID_SDK")
val jarsigner: String = findToolInPath("jarsigner")
?: throw Exception("Could not find jarsigner in PATH")
/*
exec {
commandLine(
zipAlign,
"-v", "4",
input.absolutePath,
output.absolutePath
)
}
*/
input.copyTo(output, true)
exec {
// -keystore $KEYSTORE -storetype $STORETYPE -storepass $STOREPASS -digestalg SHA1 -sigalg SHA256withRSA application.zip $KEYALIAS
commandLine(
jarsigner,
"-verbose",
"-sigalg", "SHA256withRSA",
"-digestalg", "SHA-256",
"-keystore", releaseKeystore,
"-storepass", releaseKeystorePassword,
output.absolutePath,
releaseKeyAlias
)
}
println("AAB signed and aligned successfully")
}

View File

@@ -5,18 +5,12 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application <application
android:name=".AppMain" android:name=".AppMain"
android:icon="@mipmap/launcher_icon"> android:icon="@mipmap/launcher_icon">
<service
android:name=".WearSyncForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -41,7 +35,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_ace"
android:roundIcon="@mipmap/ic_ace_round" > android:roundIcon="@mipmap/ic_ace_round" >
<intent-filter> <intent-filter>
@@ -55,7 +49,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_ace_f"
android:roundIcon="@mipmap/ic_ace_f_round" > android:roundIcon="@mipmap/ic_ace_f_round" >
<intent-filter> <intent-filter>
@@ -69,7 +63,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_bi"
android:roundIcon="@mipmap/ic_bi_round" > android:roundIcon="@mipmap/ic_bi_round" >
<intent-filter> <intent-filter>
@@ -83,7 +77,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_bi_f"
android:roundIcon="@mipmap/ic_bi_f_round" > android:roundIcon="@mipmap/ic_bi_f_round" >
<intent-filter> <intent-filter>
@@ -97,7 +91,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_cactus"
android:roundIcon="@mipmap/ic_cactus_round" > android:roundIcon="@mipmap/ic_cactus_round" >
<intent-filter> <intent-filter>
@@ -111,7 +105,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_cc"
android:roundIcon="@mipmap/ic_cc_round" > android:roundIcon="@mipmap/ic_cc_round" >
<intent-filter> <intent-filter>
@@ -125,7 +119,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_enby"
android:roundIcon="@mipmap/ic_enby_round" > android:roundIcon="@mipmap/ic_enby_round" >
<intent-filter> <intent-filter>
@@ -139,7 +133,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_enby_f"
android:roundIcon="@mipmap/ic_enby_f_round" > android:roundIcon="@mipmap/ic_enby_f_round" >
<intent-filter> <intent-filter>
@@ -153,7 +147,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_fidesz"
android:roundIcon="@mipmap/ic_fidesz_round" > android:roundIcon="@mipmap/ic_fidesz_round" >
<intent-filter> <intent-filter>
@@ -167,7 +161,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_filc"
android:roundIcon="@mipmap/ic_filc_round" > android:roundIcon="@mipmap/ic_filc_round" >
<intent-filter> <intent-filter>
@@ -181,7 +175,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_filco"
android:roundIcon="@mipmap/ic_filco_round" > android:roundIcon="@mipmap/ic_filco_round" >
<intent-filter> <intent-filter>
@@ -195,7 +189,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_galaxy"
android:roundIcon="@mipmap/ic_galaxy_round" > android:roundIcon="@mipmap/ic_galaxy_round" >
<intent-filter> <intent-filter>
@@ -209,7 +203,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_gay"
android:roundIcon="@mipmap/ic_gay_round" > android:roundIcon="@mipmap/ic_gay_round" >
<intent-filter> <intent-filter>
@@ -223,7 +217,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_gay_f"
android:roundIcon="@mipmap/ic_gay_f_round" > android:roundIcon="@mipmap/ic_gay_f_round" >
<intent-filter> <intent-filter>
@@ -237,7 +231,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_half_firka_2"
android:roundIcon="@mipmap/ic_half_firka_2_round" > android:roundIcon="@mipmap/ic_half_firka_2_round" >
<intent-filter> <intent-filter>
@@ -251,7 +245,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_kreta"
android:roundIcon="@mipmap/ic_kreta_round" > android:roundIcon="@mipmap/ic_kreta_round" >
<intent-filter> <intent-filter>
@@ -265,7 +259,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_lesb"
android:roundIcon="@mipmap/ic_lesb_round" > android:roundIcon="@mipmap/ic_lesb_round" >
<intent-filter> <intent-filter>
@@ -279,7 +273,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_lesb_f"
android:roundIcon="@mipmap/ic_lesb_f_round" > android:roundIcon="@mipmap/ic_lesb_f_round" >
<intent-filter> <intent-filter>
@@ -293,7 +287,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_lgbtq"
android:roundIcon="@mipmap/ic_lgbtq_round" > android:roundIcon="@mipmap/ic_lgbtq_round" >
<intent-filter> <intent-filter>
@@ -307,7 +301,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_lgbtq_f"
android:roundIcon="@mipmap/ic_lgbtq_f_round" > android:roundIcon="@mipmap/ic_lgbtq_f_round" >
<intent-filter> <intent-filter>
@@ -321,7 +315,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_lgbtqp"
android:roundIcon="@mipmap/ic_lgbtqp_round" > android:roundIcon="@mipmap/ic_lgbtqp_round" >
<intent-filter> <intent-filter>
@@ -335,7 +329,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_lgbtqp_f"
android:roundIcon="@mipmap/ic_lgbtqp_f_round" > android:roundIcon="@mipmap/ic_lgbtqp_f_round" >
<intent-filter> <intent-filter>
@@ -349,7 +343,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_lidl"
android:roundIcon="@mipmap/ic_lidl_round" > android:roundIcon="@mipmap/ic_lidl_round" >
<intent-filter> <intent-filter>
@@ -363,7 +357,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_mkkp"
android:roundIcon="@mipmap/ic_mkkp_round" > android:roundIcon="@mipmap/ic_mkkp_round" >
<intent-filter> <intent-filter>
@@ -377,7 +371,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_nuke"
android:roundIcon="@mipmap/ic_nuke_round" > android:roundIcon="@mipmap/ic_nuke_round" >
<intent-filter> <intent-filter>
@@ -391,7 +385,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_modern"
android:roundIcon="@mipmap/ic_modern_round" > android:roundIcon="@mipmap/ic_modern_round" >
<intent-filter> <intent-filter>
@@ -405,7 +399,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_o1g"
android:roundIcon="@mipmap/ic_o1g_round" > android:roundIcon="@mipmap/ic_o1g_round" >
<intent-filter> <intent-filter>
@@ -419,7 +413,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_old"
android:roundIcon="@mipmap/ic_old_round" > android:roundIcon="@mipmap/ic_old_round" >
<intent-filter> <intent-filter>
@@ -433,7 +427,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_paper"
android:roundIcon="@mipmap/ic_paper_round" > android:roundIcon="@mipmap/ic_paper_round" >
<intent-filter> <intent-filter>
@@ -447,7 +441,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_pear"
android:roundIcon="@mipmap/ic_pear_round" > android:roundIcon="@mipmap/ic_pear_round" >
<intent-filter> <intent-filter>
@@ -461,7 +455,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_pixel"
android:roundIcon="@mipmap/ic_pixel_round" > android:roundIcon="@mipmap/ic_pixel_round" >
<intent-filter> <intent-filter>
@@ -475,7 +469,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_pixelized"
android:roundIcon="@mipmap/ic_pixelized_round" > android:roundIcon="@mipmap/ic_pixelized_round" >
<intent-filter> <intent-filter>
@@ -489,7 +483,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_pride"
android:roundIcon="@mipmap/ic_pride_round" > android:roundIcon="@mipmap/ic_pride_round" >
<intent-filter> <intent-filter>
@@ -503,7 +497,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_proto"
android:roundIcon="@mipmap/ic_proto_round" > android:roundIcon="@mipmap/ic_proto_round" >
<intent-filter> <intent-filter>
@@ -517,7 +511,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_refilc"
android:roundIcon="@mipmap/ic_refilc_round" > android:roundIcon="@mipmap/ic_refilc_round" >
<intent-filter> <intent-filter>
@@ -531,7 +525,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_refulc"
android:roundIcon="@mipmap/ic_refulc_round" > android:roundIcon="@mipmap/ic_refulc_round" >
<intent-filter> <intent-filter>
@@ -545,7 +539,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_repont"
android:roundIcon="@mipmap/ic_repont_round" > android:roundIcon="@mipmap/ic_repont_round" >
<intent-filter> <intent-filter>
@@ -559,7 +553,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_szivacs"
android:roundIcon="@mipmap/ic_szivacs_round" > android:roundIcon="@mipmap/ic_szivacs_round" >
<intent-filter> <intent-filter>
@@ -573,7 +567,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_tisza"
android:roundIcon="@mipmap/ic_tisza_round" > android:roundIcon="@mipmap/ic_tisza_round" >
<intent-filter> <intent-filter>
@@ -587,7 +581,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_trans"
android:roundIcon="@mipmap/ic_trans_round" > android:roundIcon="@mipmap/ic_trans_round" >
<intent-filter> <intent-filter>
@@ -601,7 +595,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_trans_f"
android:roundIcon="@mipmap/ic_trans_f_round" > android:roundIcon="@mipmap/ic_trans_f_round" >
<intent-filter> <intent-filter>
@@ -615,7 +609,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_void_icon"
android:roundIcon="@mipmap/ic_void_icon_round" > android:roundIcon="@mipmap/ic_void_icon_round" >
<intent-filter> <intent-filter>
@@ -629,7 +623,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_xmas1"
android:roundIcon="@mipmap/ic_xmas1_round" > android:roundIcon="@mipmap/ic_xmas1_round" >
<intent-filter> <intent-filter>
@@ -643,7 +637,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_xmas2"
android:roundIcon="@mipmap/ic_xmas2_round" > android:roundIcon="@mipmap/ic_xmas2_round" >
<intent-filter> <intent-filter>
@@ -657,7 +651,7 @@
android:targetActivity=".MainActivity" android:targetActivity=".MainActivity"
android:exported="true" android:exported="true"
android:enabled="false" android:enabled="false"
android:icon="@mipmap/launcher_icon" android:icon="@mipmap/ic_xmas3"
android:roundIcon="@mipmap/ic_xmas3_round" > android:roundIcon="@mipmap/ic_xmas3_round" >
<intent-filter> <intent-filter>

View File

@@ -1,5 +1,99 @@
package app.firka.naplo package app.firka.naplo
import android.annotation.SuppressLint
import android.app.Application import android.app.Application
import android.os.Build
import android.util.Log
import org.brotli.dec.BrotliInputStream
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.zip.ZipFile
class AppMain : Application() {} class AppMain : Application() {
private fun File.sha256(): String {
if (!exists()) return "0000000000000000000000000000000000000000000000000000000000000000"
val md = MessageDigest.getInstance("SHA-256")
val digest = md.digest(this.readBytes())
return digest.fold("") { str, it -> str + "%02x".format(it) }
}
@SuppressLint("UnsafeDynamicallyLoadedCode")
override fun onCreate() {
super.onCreate()
var useUncompressedLibs = false
val abi = Build.SUPPORTED_ABIS[0]
val apks = File(applicationInfo.nativeLibraryDir, "../..").absoluteFile
.listFiles()!!
.filter { file -> file.name.endsWith(".apk") }
.toList()
var nativesApkN: ZipFile? = null
for (apk in apks) {
if (nativesApkN != null) break
val zip = ZipFile(apk)
val entries = zip.entries()
while (entries.hasMoreElements()) {
val entry = entries.nextElement()
if (entry.name.endsWith("$abi/index.so")) {
zip.close()
nativesApkN = ZipFile(apk)
break
}
if (entry.name.endsWith("$abi/libflutter.so")) {
useUncompressedLibs = true
break
}
}
zip.close()
}
if (useUncompressedLibs) {
return;
}
if (nativesApkN == null) {
throw Exception("Can't find native libraries")
}
val nativesApk: ZipFile = nativesApkN
val compressedLibsIndex = nativesApk.getInputStream(
nativesApk.getEntry("lib/$abi/index.so")
)
val compressedLibs = JSONObject(compressedLibsIndex.readBytes().toString(Charsets.UTF_8))
for (so in compressedLibs.keys()) {
val soFile = File(cacheDir, so)
if (soFile.sha256() == compressedLibs.getString(so)) {
System.load(soFile.absolutePath)
return
}
Log.d("AppMain", "Decompressing: $so")
val brInput = nativesApk.getInputStream(
nativesApk.getEntry("lib/$abi/${so.replace(".so", "-br.so")}")
)
val soOutput = FileOutputStream(soFile)
val brIn = BrotliInputStream(brInput)
brIn.copyTo(soOutput)
brInput.close()
soOutput.close()
System.load(soFile.absolutePath)
}
}
}

View File

@@ -1,28 +1,19 @@
package app.firka.naplo package app.firka.naplo
import android.appwidget.AppWidgetManager
import android.content.ComponentName import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.glance.appwidget.updateAll
import app.firka.naplo.glance.TimetableWidget
import app.firka.naplo.glance.TimetableWidgetReceiver
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlin.system.exitProcess import kotlin.system.exitProcess
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val channel = "firka.app/main" private val channel = "firka.app/main"
private val wearSyncChannel = "app.firka/wear_sync"
private fun forceIconUpdate() { private fun forceIconUpdate() {
try { try {
@@ -39,57 +30,6 @@ class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, wearSyncChannel).setMethodCallHandler { call, result ->
when (call.method) {
"startWearSyncService" -> {
val args = call.arguments as? Map<*, *>
val cachePath = args?.get("cachePath") as? String
val appDirPath = args?.get("appDirPath") as? String
if (cachePath != null && appDirPath != null) {
val messenger = flutterEngine.dartExecutor.binaryMessenger
val ch = MethodChannel(messenger, wearSyncChannel)
ch.invokeMethod("getLocalizedString", "wearSyncNotificationTitle", object : MethodChannel.Result {
override fun success(titleResult: Any?) {
val title = titleResult as? String ?: "Syncing with watch"
ch.invokeMethod("getLocalizedString", "wearSyncNotificationText", object : MethodChannel.Result {
override fun success(textResult: Any?) {
val text = textResult as? String ?: ""
val intent = Intent(this@MainActivity, WearSyncForegroundService::class.java).apply {
action = WearSyncForegroundService.ACTION_START
putExtra(WearSyncForegroundService.EXTRA_CACHE_PATH, cachePath)
putExtra(WearSyncForegroundService.EXTRA_APP_DIR_PATH, appDirPath)
putExtra(WearSyncForegroundService.EXTRA_NOTIFICATION_TITLE, title)
putExtra(WearSyncForegroundService.EXTRA_NOTIFICATION_TEXT, text)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
result.success(null)
}
override fun error(code: String, msg: String?, details: Any?) { result.success(null) }
override fun notImplemented() { result.success(null) }
})
}
override fun error(code: String, msg: String?, details: Any?) { result.error(code, msg, details) }
override fun notImplemented() { result.notImplemented() }
})
} else {
result.error("INVALID_ARGS", "cachePath and appDirPath required", null)
}
}
"stopWearSyncService" -> {
val intent = Intent(this, WearSyncForegroundService::class.java).apply {
action = WearSyncForegroundService.ACTION_STOP
}
startService(intent)
result.success(null)
}
else -> result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel).setMethodCallHandler { MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel).setMethodCallHandler {
call, result -> call, result ->
when (call.method) { when (call.method) {
@@ -157,29 +97,7 @@ class MainActivity : FlutterActivity() {
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
result.success(true) result.success(true)
}
"refreshTimetableWidget" -> {
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
try {
val appContext = context.applicationContext
val appWidgetManager = AppWidgetManager.getInstance(appContext)
val componentName = ComponentName(appContext, TimetableWidgetReceiver::class.java)
val ids = appWidgetManager.getAppWidgetIds(componentName)
if (ids.isNotEmpty()) {
val intent = Intent(appContext, TimetableWidgetReceiver::class.java).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
}
appContext.sendBroadcast(intent)
}
TimetableWidget().updateAll(appContext)
result.success(true)
} catch (e: Exception) {
result.error("refresh_failed", e.message, null)
}
}
} }
else -> { else -> {
result.notImplemented() result.notImplemented()

View File

@@ -1,221 +0,0 @@
package app.firka.naplo
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import java.io.ByteArrayInputStream
import java.io.ObjectInputStream
import com.google.android.gms.wearable.MessageClient
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.Wearable
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.embedding.engine.loader.FlutterLoader
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.delay
/**
* Foreground service that keeps the app able to respond to Wear OS sync requests.
* When the watch sends request_sync, starts a Dart background isolate to fetch data,
* then reads the cache file and sends sync_data to the watch.
*/
class WearSyncForegroundService : Service(), MessageClient.OnMessageReceivedListener {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var cachePath: String? = null
private var appDirPath: String? = null
private val channelId = "firka_wear_sync"
private val notificationId = 4001
private var notificationTitle: String = "Syncing with watch"
private var notificationText: String = ""
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
cachePath = intent.getStringExtra(EXTRA_CACHE_PATH)
appDirPath = intent.getStringExtra(EXTRA_APP_DIR_PATH)
notificationTitle = intent.getStringExtra(EXTRA_NOTIFICATION_TITLE) ?: "Syncing with watch"
notificationText = intent.getStringExtra(EXTRA_NOTIFICATION_TEXT) ?: ""
startForegroundWithNotification()
Wearable.getMessageClient(this@WearSyncForegroundService)
.addListener(this@WearSyncForegroundService)
}
ACTION_STOP -> {
stopForegroundService()
return START_NOT_STICKY
}
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
try {
Wearable.getMessageClient(this@WearSyncForegroundService)
.removeListener(this@WearSyncForegroundService)
.addOnCompleteListener { }
} catch (_: Exception) { }
scope.cancel()
super.onDestroy()
}
override fun onMessageReceived(messageEvent: MessageEvent) {
if (messageEvent.path != PATH_WATCH_CONNECTIVITY ||
!isRequestSyncPayload(messageEvent.data)
) return
val cPath = cachePath
val aPath = appDirPath
if (cPath == null || aPath == null) return
scope.launch {
runSyncInBackground(cPath, aPath)
}
}
/**
* watch_connectivity plugin sends with path "watch_connectivity" and serializes the message
* map with Java ObjectOutputStream. Parse payload and check for id == "request_sync".
*/
private fun isRequestSyncPayload(data: ByteArray?): Boolean {
if (data == null || data.isEmpty()) return false
return try {
ObjectInputStream(ByteArrayInputStream(data)).use { ois ->
val map = ois.readObject()
if (map is Map<*, *>) map["id"] == "request_sync" else false
}
} catch (_: Exception) {
false
}
}
private fun startForegroundWithNotification() {
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(notificationId, notification, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
@Suppress("DEPRECATION")
startForeground(notificationId, notification)
}
}
private fun buildNotification(): Notification {
val pendingIntent = PendingIntent.getActivity(
this,
0,
packageManager.getLaunchIntentForPackage(packageName),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, channelId)
.setContentTitle(notificationTitle)
.setContentText(notificationText)
.setSmallIcon(R.drawable.ic_notification)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId,
"Wear sync",
NotificationManager.IMPORTANCE_LOW
).apply { setShowBadge(false) }
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
.createNotificationChannel(channel)
}
}
private fun stopForegroundService() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
stopSelf()
}
private suspend fun runSyncInBackground(cPath: String, aPath: String) = withContext(Dispatchers.Default) {
val flutterLoader = FlutterLoader()
if (!flutterLoader.initialized()) {
withContext(Dispatchers.Main) {
flutterLoader.startInitialization(applicationContext)
flutterLoader.ensureInitializationComplete(applicationContext, null)
}
}
val (engine, bgChannel) = withContext(Dispatchers.Main) {
val eng = FlutterEngine(applicationContext)
val entrypoint = DartExecutor.DartEntrypoint(
flutterLoader.findAppBundlePath(),
"package:firka/services/wear_sync_background.dart",
"wearSyncBackgroundEntrypoint"
)
eng.dartExecutor.executeDartEntrypoint(entrypoint)
val ch = MethodChannel(eng.dartExecutor.binaryMessenger, "app.firka/wear_sync_background")
Pair(eng, ch)
}
val completer = CompletableDeferred<Unit>()
delay(500)
withContext(Dispatchers.Main) {
bgChannel.invokeMethod("request_sync", mapOf(
"cachePath" to cPath,
"appDirPath" to aPath
), object : MethodChannel.Result {
override fun success(result: Any?) {
completer.complete(Unit)
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
Log.e(TAG, "request_sync error: $errorCode $errorMessage")
completer.complete(Unit)
}
override fun notImplemented() {
completer.complete(Unit)
}
})
}
try {
withTimeout(30_000) {
completer.await()
}
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
Log.w(TAG, "Wear sync isolate timed out")
}
withContext(Dispatchers.Main) {
engine.destroy()
}
}
companion object {
private const val TAG = "WearSyncService"
const val ACTION_START = "app.firka.naplo.WearSyncForegroundService.START"
const val ACTION_STOP = "app.firka.naplo.WearSyncForegroundService.STOP"
const val EXTRA_CACHE_PATH = "cachePath"
const val EXTRA_APP_DIR_PATH = "appDirPath"
const val EXTRA_NOTIFICATION_TITLE = "notificationTitle"
const val EXTRA_NOTIFICATION_TEXT = "notificationText"
private const val PATH_WATCH_CONNECTIVITY = "watch_connectivity"
}
}

View File

@@ -18,7 +18,7 @@ import androidx.glance.text.FontWeight
import androidx.glance.text.Text import androidx.glance.text.Text
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import app.firka.naplo.model.Colors import app.firka.naplo.model.Colors
import app.firka.naplo.glance.WidgetLesson import app.firka.naplo.model.Lesson
import java.time.format.DateTimeFormatterBuilder import java.time.format.DateTimeFormatterBuilder
val hhmm = DateTimeFormatterBuilder() val hhmm = DateTimeFormatterBuilder()
@@ -26,12 +26,8 @@ val hhmm = DateTimeFormatterBuilder()
.toFormatter() .toFormatter()
@Composable @Composable
fun LessonCard( fun LessonCard(lesson: Lesson, colors: Colors,
lesson: WidgetLesson, modifier: GlanceModifier = GlanceModifier) {
colors: Colors,
modifier: GlanceModifier = GlanceModifier,
roomBadgeWidthDp: Float = 48f,
) {
Box(modifier = Box(modifier =
modifier modifier
.fillMaxWidth() .fillMaxWidth()
@@ -42,7 +38,7 @@ fun LessonCard(
var bgColor = colors.a15p var bgColor = colors.a15p
var fgColor = colors.textSecondary var fgColor = colors.textSecondary
if (lesson.substituteTeacher != null) { if (lesson.substituteTeacher == null) {
bgColor = colors.warning15p bgColor = colors.warning15p
fgColor = colors.warningText fgColor = colors.warningText
} }
@@ -50,21 +46,9 @@ fun LessonCard(
Box(modifier = GlanceModifier.padding(12.dp)) { Box(modifier = GlanceModifier.padding(12.dp)) {
Row { Row {
val badgeStyle = TextStyle(
color = ColorProvider(colors.textSecondary, colors.textSecondary),
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
val badgePadding = GlanceModifier.padding(8.dp, 4.dp)
val lessonNumberBadgeModifier = GlanceModifier.cornerRadius(16.dp).width(24.dp)
val roomBadgeModifier = GlanceModifier.cornerRadius(16.dp).width(roomBadgeWidthDp.dp)
Row(modifier = GlanceModifier.width(226.dp), verticalAlignment = Alignment.CenterVertically) { Row(modifier = GlanceModifier.width(226.dp), verticalAlignment = Alignment.CenterVertically) {
if (lesson.lessonNumber != null) { if (lesson.lessonNumber != null) {
Box( Box(modifier = GlanceModifier.cornerRadius(16.dp).background(bgColor)) {
modifier = lessonNumberBadgeModifier.background(bgColor),
contentAlignment = Alignment.Center,
) {
Text( Text(
lesson.lessonNumber.toString(), lesson.lessonNumber.toString(),
style = TextStyle( style = TextStyle(
@@ -72,7 +56,7 @@ fun LessonCard(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
), ),
modifier = GlanceModifier.padding(4.dp, 4.dp), modifier = GlanceModifier.padding(8.dp, 4.dp),
) )
} }
Spacer(modifier = GlanceModifier.width(4.dp)) Spacer(modifier = GlanceModifier.width(4.dp))
@@ -88,6 +72,8 @@ fun LessonCard(
) )
} }
// Spacer(modifier = GlanceModifier.width(10.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
lesson.start.format(hhmm), lesson.start.format(hhmm),
@@ -98,15 +84,24 @@ fun LessonCard(
), ),
) )
Spacer(modifier = GlanceModifier.width(8.dp)) Spacer(modifier = GlanceModifier.width(8.dp))
val roomName = (lesson.roomName ?: "N/A").take(5) Box(modifier = GlanceModifier.cornerRadius(16.dp).background(colors.a15p)) {
Box( var roomName = "N/A";
modifier = roomBadgeModifier.background(colors.a15p), if (lesson.roomName != null) {
contentAlignment = Alignment.Center, roomName = lesson.roomName!!;
) { }
if (roomName.length < 2) {
roomName = " $roomName"
}
Text( Text(
roomName, roomName,
style = badgeStyle, style = TextStyle(
modifier = GlanceModifier.padding(4.dp, 4.dp), color = ColorProvider(colors.textSecondary, colors.textSecondary),
fontSize = 14.sp,
fontWeight = FontWeight.Bold
),
modifier = GlanceModifier.padding(8.dp, 4.dp),
) )
} }
} }

View File

@@ -9,10 +9,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.glance.GlanceId import androidx.glance.GlanceId
import androidx.glance.GlanceModifier import androidx.glance.GlanceModifier
import androidx.glance.LocalSize
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.SizeMode
import androidx.glance.background import androidx.glance.background
import androidx.glance.color.ColorProvider import androidx.glance.color.ColorProvider
import androidx.glance.currentState import androidx.glance.currentState
@@ -28,9 +26,7 @@ import androidx.glance.text.FontWeight
import androidx.glance.text.Text import androidx.glance.text.Text
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import app.firka.naplo.model.Colors import app.firka.naplo.model.Colors
import app.firka.naplo.glance.WidgetLesson import app.firka.naplo.model.Lesson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject import org.json.JSONObject
import java.io.File import java.io.File
import java.time.LocalDate import java.time.LocalDate
@@ -41,51 +37,20 @@ class TimetableWidget : GlanceAppWidget() {
override val stateDefinition: GlanceStateDefinition<*>? override val stateDefinition: GlanceStateDefinition<*>?
get() = HomeWidgetGlanceStateDefinition() get() = HomeWidgetGlanceStateDefinition()
override val sizeMode: SizeMode
get() = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val data = withContext(Dispatchers.IO) {
loadWidgetData(context)
}
provideContent { provideContent {
GlanceContent(context, currentState(), data) GlanceContent(context, currentState())
} }
} }
private fun loadWidgetData(context: Context): WidgetData? {
val appFlutter = File(context.applicationContext.dataDir, "app_flutter")
val widgetStateFile = File(appFlutter, "widget_state.json")
if (!widgetStateFile.exists()) return null
val widgetState = JSONObject(widgetStateFile.readText(Charsets.UTF_8))
val colors = Colors(widgetState)
val tt = widgetState.getJSONArray("timetable")
val lessons = mutableListOf<WidgetLesson>()
for (i in 0..<tt.length()) {
lessons.add(WidgetLesson(tt.getJSONObject(i)))
}
val displayDateStr = widgetState.optString("displayDate", "")
val targetDate = if (displayDateStr.isNotEmpty()) {
try {
LocalDate.parse(displayDateStr)
} catch (_: Exception) {
LocalDate.now()
}
} else {
LocalDate.now()
}
val start = LocalDateTime.of(targetDate.year, targetDate.month, targetDate.dayOfMonth, 0, 0)
val end = start.plusHours(23)
val filtered = lessons.filter { it.start.isAfter(start) && it.end.isBefore(end) }
val headerText = if (displayDateStr.isNotEmpty()) displayDateStr else "Mai órarend"
return WidgetData(colors, headerText, filtered)
}
@Composable @Composable
private fun GlanceContent(context: Context, currentState: HomeWidgetGlanceState, data: WidgetData?) { private fun GlanceContent(context: Context, currentState: HomeWidgetGlanceState) {
if (data == null) { val appFlutter = File(context.applicationContext.dataDir, "app_flutter")
Box( val widgetStateFile = File(appFlutter, "widget_state.json")
modifier = GlanceModifier
if (!widgetStateFile.exists()) {
Box(modifier =
GlanceModifier
.background(Color(0xFFFAFFF0)) .background(Color(0xFFFAFFF0))
.padding(16.dp) .padding(16.dp)
.fillMaxSize(), .fillMaxSize(),
@@ -100,67 +65,47 @@ class TimetableWidget : GlanceAppWidget() {
) )
) )
} }
return return
} }
val size = LocalSize.current val widgetState = JSONObject(widgetStateFile.readText(Charsets.UTF_8))
val lessonRowHeightDp = 52f val colors = Colors(widgetState)
val scale = lessonRowHeightDp / 52f
val headerHeightDp = 20f * scale
val verticalPaddingDp = 32f * scale
val spacerDp = 4f * scale
val paddingDp = 16f * scale
val availableHeightDp = size.height.value - verticalPaddingDp - headerHeightDp - spacerDp
val maxVisibleLessons = (availableHeightDp / lessonRowHeightDp).toInt().coerceAtLeast(0)
val maxLessons = (maxVisibleLessons.coerceAtMost(16) / 2 * 2).coerceAtLeast(1)
val displayLessons = data.lessons.take(maxLessons)
val lessonChunks = displayLessons.chunked(2)
val showDate = maxLessons > 1
val maxRoomNameLen = displayLessons.maxOfOrNull { (it.roomName ?: "N/A").take(5).length } ?: 0
val roomBadgeWidthDp = if (maxRoomNameLen <= 3) 28f else 48f
val dateSectionHeight = if (showDate) headerHeightDp + spacerDp else 0f
val lessonListHeight = when (val n = displayLessons.size) {
0 -> 0f
else -> n * lessonRowHeightDp + (n - 1) * spacerDp
}
val remainingHeight = (size.height.value - 2 * paddingDp - dateSectionHeight - lessonListHeight).coerceAtLeast(0f)
val verticalPaddingAroundLessons = remainingHeight / 2f
Box( val tt = widgetState.getJSONArray("timetable")
modifier = GlanceModifier var lessons = mutableListOf<Lesson>()
.background(data.colors.background)
.padding(paddingDp.dp) for (i in 0..<tt.length()) {
lessons.add(Lesson(tt.getJSONObject(i)))
}
val now = LocalDate.now()
val start = LocalDateTime.of(now.year, now.month, now.dayOfMonth, 0, 0)
val end = start.plusHours(23)
lessons = lessons.filter { lesson -> lesson.start.isAfter(start) && lesson.end.isBefore(end) }.toMutableList()
Box(modifier =
GlanceModifier
.background(colors.background)
.padding(16.dp)
.fillMaxSize() .fillMaxSize()
) { ) {
Column { Column {
if (showDate) { Text(
Text( "Mai órarend",
data.headerText, style = TextStyle(
style = TextStyle( color = ColorProvider(colors.textSecondary, colors.textSecondary),
color = ColorProvider(data.colors.textSecondary, data.colors.textSecondary), fontSize = 12.sp,
fontSize = 12.sp, fontWeight = FontWeight.Medium
fontWeight = FontWeight.Medium
)
) )
Spacer(modifier = GlanceModifier.height(spacerDp.dp)) )
Spacer(modifier = GlanceModifier.height(4.dp))
for (lesson in lessons) {
LessonCard(lesson, colors)
Spacer(modifier = GlanceModifier.height(4.dp))
} }
Spacer(modifier = GlanceModifier.height(verticalPaddingAroundLessons.dp))
for (chunk in lessonChunks) {
Column {
for (lesson in chunk) {
LessonCard(lesson, data.colors, roomBadgeWidthDp = roomBadgeWidthDp)
Spacer(modifier = GlanceModifier.height(spacerDp.dp))
}
}
}
Spacer(modifier = GlanceModifier.height(verticalPaddingAroundLessons.dp))
} }
} }
} }
}
private data class WidgetData( }
val colors: Colors,
val headerText: String,
val lessons: List<WidgetLesson>,
)

View File

@@ -1,25 +1,7 @@
package app.firka.naplo.glance package app.firka.naplo.glance
import android.appwidget.AppWidgetManager
import android.content.Context
import android.os.Bundle
import HomeWidgetGlanceWidgetReceiver import HomeWidgetGlanceWidgetReceiver
import androidx.glance.appwidget.GlanceAppWidgetManager
import kotlinx.coroutines.runBlocking
class TimetableWidgetReceiver : HomeWidgetGlanceWidgetReceiver<TimetableWidget>() { class TimetableWidgetReceiver : HomeWidgetGlanceWidgetReceiver<TimetableWidget>() {
override val glanceAppWidget = TimetableWidget() override val glanceAppWidget = TimetableWidget()
override fun onAppWidgetOptionsChanged(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
newOptions: Bundle,
) {
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
runBlocking {
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
glanceAppWidget.update(context, glanceId)
}
}
} }

View File

@@ -1,23 +0,0 @@
package app.firka.naplo.glance
import app.firka.naplo.getIntOrNull
import app.firka.naplo.getStringOrNull
import org.json.JSONObject
import java.time.LocalDateTime
import java.time.format.DateTimeFormatterBuilder
class WidgetLesson(data: JSONObject) {
val formatter = DateTimeFormatterBuilder()
.appendPattern("yyyy-MM-dd'T'HH:mm:ss.SSS")
.optionalStart()
.appendLiteral('Z')
.optionalEnd()
.toFormatter()
val name: String = data.getString("Nev")
val start: LocalDateTime = LocalDateTime.parse(data.getString("KezdetIdopont"), formatter)
val end: LocalDateTime = LocalDateTime.parse(data.getString("VegIdopont"), formatter)
val lessonNumber: Int? = data.getIntOrNull("Oraszam")
val roomName: String? = data.getStringOrNull("TeremNeve")
val substituteTeacher: String? = data.getStringOrNull("HelyettesTanarNeve")
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -8,7 +8,6 @@
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#7ca120</item> <item name="android:windowSplashScreenBackground">#7ca120</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item> <item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">#7ca120</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your This theme determines the color of the Android Window while your

View File

@@ -8,7 +8,6 @@
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#7ca120</item> <item name="android:windowSplashScreenBackground">#7ca120</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item> <item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">#7ca120</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your This theme determines the color of the Android Window while your

View File

@@ -1,9 +1,7 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout" android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="250dp" android:minWidth="300dp"
android:minHeight="93dp" android:minHeight="100dp"
android:minResizeWidth="180dp"
android:minResizeHeight="93dp"
android:resizeMode="horizontal|vertical" android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"> android:updatePeriodMillis="10000">
</appwidget-provider> </appwidget-provider>

View File

@@ -22,8 +22,8 @@ subprojects {
if (plugins.hasPlugin("com.android.application") || plugins.hasPlugin("com.android.library")) { if (plugins.hasPlugin("com.android.application") || plugins.hasPlugin("com.android.library")) {
val androidExtension = extensions.getByName("android") as BaseExtension val androidExtension = extensions.getByName("android") as BaseExtension
androidExtension.apply { androidExtension.apply {
compileSdkVersion(36) compileSdkVersion(35)
buildToolsVersion = "36.1.0" buildToolsVersion = "35.0.0"
} }
} }
if (hasProperty("android")) { if (hasProperty("android")) {
@@ -40,6 +40,9 @@ subprojects {
project.layout.buildDirectory.value(newSubprojectBuildDir) project.layout.buildDirectory.value(newSubprojectBuildDir)
} }
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") { tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory) delete(rootProject.layout.buildDirectory)

View File

@@ -1,13 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
# Disabled for faster config and incremental builds; re-enable if any dependency needs support-library android.enableJetifier=true
android.enableJetifier=false
# Build performance (cold and warm)
org.gradle.caching=true
org.gradle.parallel=true
# Configuration cache disabled: Flutter/AGP/Kotlin plugin not fully compatible (KotlinBaseApiPlugin / ProjectServices)
# org.gradle.configuration-cache=true
org.gradle.daemon=true
# Better Kotlin incremental compilation (warm builds)
kotlin.incremental.useClasspathSnapshot=true

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip

View File

@@ -18,9 +18,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false id("com.android.application") version "8.7.0" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false id("org.jetbrains.kotlin.android") version "1.8.22" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false
} }
include(":app") include(":app")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -1,4 +0,0 @@
<svg width="23" height="18" viewBox="0 0 23 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.8252 0L3.09442 6.29374L2.5175 3.93359C3.26925 3.93359 3.88114 4.15212 4.35317 4.58919C4.8252 5.02625 5.06122 5.6294 5.06122 6.39864C5.06122 7.15039 4.81646 7.76228 4.32695 8.23431C3.85492 8.68886 3.26051 8.91613 2.54372 8.91613C1.80945 8.91613 1.19756 8.68886 0.708046 8.23431C0.236015 7.76228 0 7.15039 0 6.39864C0 6.17136 0.0174826 5.95283 0.0524478 5.74304C0.0874131 5.51576 0.157344 5.25352 0.262239 4.95632C0.367135 4.65912 0.515737 4.26576 0.708046 3.77624L2.22903 0H4.8252ZM11.014 0L9.28327 6.29374L8.70634 3.93359C9.45809 3.93359 10.07 4.15212 10.542 4.58919C11.014 5.02625 11.2501 5.6294 11.2501 6.39864C11.2501 7.15039 11.0053 7.76228 10.5158 8.23431C10.0438 8.68886 9.44935 8.91613 8.73256 8.91613C7.99829 8.91613 7.3864 8.68886 6.89689 8.23431C6.42486 7.76228 6.18884 7.15039 6.18884 6.39864C6.18884 6.17136 6.20633 5.95283 6.24129 5.74304C6.27626 5.51576 6.34619 5.25352 6.45108 4.95632C6.55598 4.65912 6.70458 4.26576 6.89689 3.77624L8.41788 0H11.014Z" fill="#A0D025"/>
<path d="M17.6748 17.832L19.4056 11.5383L19.9825 13.8984C19.2308 13.8984 18.6189 13.6799 18.1468 13.2428C17.6748 12.8058 17.4388 12.2026 17.4388 11.4334C17.4388 10.6816 17.6835 10.0698 18.1731 9.59772C18.6451 9.14317 19.2395 8.9159 19.9563 8.9159C20.6905 8.9159 21.3024 9.14317 21.792 9.59772C22.264 10.0698 22.5 10.6816 22.5 11.4334C22.5 11.6607 22.4825 11.8792 22.4476 12.089C22.4126 12.3163 22.3427 12.5785 22.2378 12.8757C22.1329 13.1729 21.9843 13.5663 21.792 14.0558L20.271 17.832H17.6748ZM11.486 17.832L13.2167 11.5383L13.7937 13.8984C13.0419 13.8984 12.43 13.6799 11.958 13.2428C11.486 12.8058 11.2499 12.2026 11.2499 11.4334C11.2499 10.6816 11.4947 10.0698 11.9842 9.59772C12.4562 9.14317 13.0506 8.9159 13.7674 8.9159C14.5017 8.9159 15.1136 9.14317 15.6031 9.59772C16.0751 10.0698 16.3112 10.6816 16.3112 11.4334C16.3112 11.6607 16.2937 11.8792 16.2587 12.089C16.2237 12.3163 16.1538 12.5785 16.0489 12.8757C15.944 13.1729 15.7954 13.5663 15.6031 14.0558L14.0821 17.832H11.486Z" fill="#A0D025"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1,4 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.2187 15.3754C21.5637 14.9441 22.1937 14.8741 22.6249 15.2191C23.0562 15.5641 23.1262 16.1941 22.7812 16.6254L18.7812 21.6254C18.6036 21.8474 18.3394 21.9826 18.0556 21.9984C17.7716 22.0142 17.494 21.9086 17.2929 21.7074L15.2929 19.7074C14.9024 19.3169 14.9024 18.6839 15.2929 18.2934C15.6834 17.9028 16.3164 17.9028 16.707 18.2934L17.9159 19.5023L21.2187 15.3754Z" fill="#A0D025"/>
<path d="M10.7998 3.65137C11.146 3.39172 11.5673 3.25098 12 3.25098C12.4327 3.25098 12.854 3.39172 13.2002 3.65137L20.2002 8.90137C20.4484 9.08763 20.6503 9.32886 20.7891 9.60645C20.9279 9.88415 21 10.1905 21 10.501V12.4565C21 12.9796 20.4396 13.3258 19.9346 13.1894C19.4773 13.066 18.9964 13 18.5 13C16.5152 13 14.776 14.0513 13.8089 15.6274C13.6713 15.8515 13.4337 16.001 13.1708 16.001H13H11V19.001C11 19.5314 10.7891 20.04 10.4141 20.415C10.039 20.7901 9.53043 21.001 9 21.001H5C4.46957 21.001 3.96101 20.7901 3.58594 20.415C3.21087 20.04 3 19.5314 3 19.001V10.501C3 10.1905 3.07208 9.88416 3.21094 9.60645C3.34973 9.32885 3.55156 9.08763 3.7998 8.90137L10.7998 3.65137Z" fill="#A0D025"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,21 +1,16 @@
flutter_native_splash: flutter_native_splash:
color: "#7ca120" color: "#7ca120"
image: assets/images/logos/splash.png image: assets/images/logos/splash.png
# Keep image centered instead of fill-scaled (stops icon looking zoomed/cropped)
android_gravity: center
# Dark mode - same color as light mode for consistency # Dark mode - same color as light mode for consistency
color_dark: "#7ca120" color_dark: "#7ca120"
image_dark: assets/images/logos/splash.png image_dark: assets/images/logos/splash.png
# Android 12+ uses 960×960 image with logo in 640px circle (generated by codegen) to avoid cropping
android_12: android_12:
image: assets/images/logos/splash_android12.png image: assets/images/logos/splash.png
color: "#7ca120" color: "#7ca120"
color_dark: "#7ca120" color_dark: "#7ca120"
image_dark: assets/images/logos/splash_android12.png image_dark: assets/images/logos/splash.png
icon_background_color: "#7ca120"
icon_background_color_dark: "#7ca120"
ios: true ios: true
web: false web: false

View File

@@ -0,0 +1,42 @@
import 'package:firka/helpers/db/models/generic_cache_model.dart';
import 'package:firka/helpers/db/models/timetable_cache_model.dart';
import 'package:firka/helpers/db/models/token_model.dart';
import 'package:firka/main.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
import 'test_helpers.dart';
Future<void> main() async {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
await resetAppData();
setApiUrls();
group('main', () {
testWidgets('InitializationScreen -> HomeScreen', (tester) async {
final dir = await getApplicationDocumentsDirectory();
var isar = await Isar.open(
[TokenModelSchema, GenericCacheModelSchema, TimetableCacheModelSchema],
inspector: true,
directory: dir.path,
);
isarInit = isar;
await isar.writeTxn(() async {
await isar.tokenModels.put(TokenModel());
});
await tester.pumpWidget(InitializationScreen());
await waitUntil(Duration(minutes: 2), tester, () async {
var ele = find.byKey(const Key('homeScreen'));
return ele.allCandidates.isNotEmpty;
});
});
});
}

View File

@@ -0,0 +1,24 @@
import 'package:firka/main.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'test_helpers.dart';
Future<void> main() async {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
await resetAppData();
setApiUrls();
group('main', () {
testWidgets('InitializationScreen -> LoginScreen', (tester) async {
await tester.pumpWidget(InitializationScreen());
await waitUntil(Duration(minutes: 2), tester, () async {
var ele = find.byKey(const Key('loginScreen'));
return ele.allCandidates.isNotEmpty;
});
});
});
}

View File

@@ -0,0 +1,42 @@
import 'package:firka/helpers/api/consts.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider/path_provider.dart';
Future<bool> isWear() async {
const platform = MethodChannel('firka.app/main');
return await platform.invokeMethod("isWear");
}
Future<bool> isPhone() async {
return !(await isWear());
}
Future<void> resetAppData() async {
final isarDir = await getApplicationDocumentsDirectory();
if (await isarDir.exists()) await isarDir.delete(recursive: true);
}
void setApiUrls() {
KretaEndpoints.kretaBase = "localhost:8060";
KretaEndpoints.kretaIdp = "http://localhost:8060";
KretaEndpoints.kretaLoginUrl =
"${KretaEndpoints.kretaIdp}/Account/Login?ReturnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Fprompt%3Dlogin%26nonce%3DwylCrqT4oN6PPgQn2yQB0euKei9nJeZ6_ffJ-VpSKZU%26response_type%3Dcode%26code_challenge_method%3DS256%26scope%3Dopenid%2520email%2520offline_access%2520kreta-ellenorzo-webapi.public%2520kreta-eugyintezes-webapi.public%2520kreta-fileservice-webapi.public%2520kreta-mobile-global-webapi.public%2520kreta-dkt-webapi.public%2520kreta-ier-webapi.public%26code_challenge%3DHByZRRnPGb-Ko_wTI7ibIba1HQ6lor0ws4bcgReuYSQ%26redirect_uri%3Dhttps%253A%252F%252Fmobil.e-kreta.hu%252Fellenorzo-student%252Fprod%252Foauthredirect%26client_id%3Dkreta-ellenorzo-student-mobile-ios%26state%3Dkreta_student_mobile%26suppressed_prompt%3Dlogin";
KretaEndpoints.tokenGrantUrl = "${KretaEndpoints.kretaIdp}/connect/token";
}
Future<void> waitUntil(Duration timeout, WidgetTester tester,
Future<bool> Function() callback) async {
var now = DateTime.now();
while (
now.difference(DateTime.now()).inMilliseconds < timeout.inMilliseconds) {
await tester.pump(Duration(milliseconds: 100));
if (await callback()) {
return;
}
}
throw Exception("waitUntil timed out");
}

View File

@@ -1,77 +0,0 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:firka/api/client/kreta_client.dart';
import 'package:firka/core/bloc/home_refresh_cubit.dart';
import 'package:firka/core/bloc/profile_picture_cubit.dart';
import 'package:firka/core/bloc/reauth_cubit.dart';
import 'package:firka/core/bloc/settings_cubit.dart';
import 'package:firka/core/bloc/theme_cubit.dart';
import 'package:firka/data/models/token_model.dart';
import 'package:firka/core/settings.dart';
import 'package:firka/l10n/app_localizations.dart';
import 'package:logging/logging.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:isar_community/isar.dart';
import 'dart:io';
late final Logger logger;
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
late AppInitialization initData;
bool initDone = false;
/// Set when app router is created; used for deep links and notifications.
GoRouter? appRouter;
final dio = Dio();
final isBeta = true;
class DeviceInfo {
String model;
String versionRelease;
String versionSdkInt;
DeviceInfo(this.model, this.versionRelease, this.versionSdkInt);
@override
String toString() {
return "DeviceInfo(model = \"$model\", versionRelease = \"$versionRelease\""
", versionSdkInt = \"$versionSdkInt\"";
}
}
class AppInitialization {
final Isar isar;
final Directory appDir;
final PackageInfo packageInfo;
final DeviceInfo devInfo;
late KretaClient client;
List<TokenModel> tokens;
bool hasWatchListener = false;
/// Set by the wear pairing modal; called when watch sends init_done or sync_done to dismiss the sheet.
void Function()? dismissWearPairingSheet;
Uint8List? profilePicture;
SettingsStore settings;
ThemeCubit? themeCubit;
SettingsCubit? settingsCubit;
ProfilePictureCubit? profilePictureCubit;
ReauthCubit? reauthCubit;
HomeRefreshCubit? homeRefreshCubit;
AppLocalizations l10n;
final GlobalKey<NavigatorState> navigatorKey;
AppInitialization({
required this.isar,
required this.appDir,
required this.devInfo,
required this.packageInfo,
required this.tokens,
required this.settings,
required this.l10n,
required this.navigatorKey,
});
}

View File

@@ -1,407 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:firka/app/app_state.dart';
import 'package:firka/core/bloc/home_refresh_cubit.dart';
import 'package:firka/core/bloc/profile_picture_cubit.dart';
import 'package:firka/core/bloc/reauth_cubit.dart';
import 'package:firka/core/bloc/settings_cubit.dart';
import 'package:firka/core/bloc/theme_cubit.dart';
import 'package:firka/services/active_account_helper.dart';
import 'package:firka/api/client/kreta_client.dart';
import 'package:firka/data/models/app_settings_model.dart';
import 'package:firka/data/models/generic_cache_model.dart';
import 'package:firka/data/models/homework_cache_model.dart';
import 'package:firka/data/models/timetable_cache_model.dart';
import 'package:firka/data/models/token_model.dart';
import 'package:firka/services/live_activity_service.dart';
import 'package:firka/core/settings.dart';
import 'package:firka/services/watch_sync_helper.dart';
import 'package:firka/l10n/app_localizations_de.dart';
import 'package:firka/l10n/app_localizations_en.dart';
import 'package:firka/l10n/app_localizations_hu.dart';
import 'package:firka/core/swear_generator.dart';
import 'package:firka/ui/theme/style.dart';
import 'package:intl/intl.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:isar_community/isar.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
Isar? isarInit;
Future<Isar> initDB() async {
if (isarInit != null) return isarInit!;
final dir = await getApplicationDocumentsDirectory();
isarInit = await Isar.open(
[
TokenModelSchema,
GenericCacheModelSchema,
TimetableCacheModelSchema,
HomeworkCacheModelSchema,
AppSettingsModelSchema,
HomeworkDoneModelSchema,
],
inspector: true,
directory: dir.path,
);
return isarInit!;
}
Future<void> initLang(AppInitialization data) async {
String? languageCode;
switch ((data.settings.group("settings").subGroup("application")["language"]
as SettingsItemsRadio)
.activeIndex) {
case 1: // hu
data.l10n = AppLocalizationsHu();
languageCode = 'hu';
break;
case 2: // en
data.l10n = AppLocalizationsEn();
languageCode = 'en';
break;
case 3: // de
data.l10n = AppLocalizationsDe();
languageCode = 'de';
break;
default: // auto
switch (ui.PlatformDispatcher.instance.locale.languageCode) {
case 'hu':
data.l10n = AppLocalizationsHu();
languageCode = 'hu';
break;
case 'en':
data.l10n = AppLocalizationsEn();
languageCode = 'en';
break;
case 'de':
data.l10n = AppLocalizationsDe();
languageCode = 'de';
break;
}
break;
}
if (languageCode != null && Platform.isIOS) {
try {
await LiveActivityService.updateLanguagePreference(languageCode);
} catch (e) {
logger.warning('Failed to update language preference on backend: $e');
}
try {
await WatchSyncHelper.sendLanguageToWatch();
} catch (e) {
logger.warning('Failed to send language to Watch: $e');
}
}
}
void initTheme(AppInitialization data) {
final themeCubit = data.themeCubit;
if (themeCubit == null) return;
final brightness =
SchedulerBinding.instance.platformDispatcher.platformBrightness;
switch ((data.settings.group("settings").subGroup("customization")["theme"]
as SettingsItemsRadio)
.activeIndex) {
case 1:
appStyle = lightStyle;
themeCubit.setLightMode(true);
break;
case 2:
appStyle = darkStyle;
themeCubit.setLightMode(false);
break;
default:
if (brightness == Brightness.dark) {
appStyle = darkStyle;
themeCubit.setLightMode(false);
} else {
appStyle = lightStyle;
themeCubit.setLightMode(true);
}
}
}
Future<void> _initData(AppInitialization init) async {
init.themeCubit ??= ThemeCubit();
init.settingsCubit ??= SettingsCubit();
init.profilePictureCubit ??= ProfilePictureCubit();
init.reauthCubit ??= ReauthCubit();
init.homeRefreshCubit ??= HomeRefreshCubit();
await init.settings.load(init.isar.appSettingsModels);
await initLang(init);
initTheme(init);
init.settings = SettingsStore(init.l10n);
await init.settings.load(init.isar.appSettingsModels);
var dispatcher = SchedulerBinding.instance.platformDispatcher;
dispatcher.onPlatformBrightnessChanged = () {
initTheme(init);
};
dispatcher.onLocaleChanged = () {
final languageSetting =
init.settings.group("settings").subGroup("application")["language"]
as SettingsItemsRadio;
final isAutoLanguage = languageSetting.activeIndex == 0;
if (!isAutoLanguage) {
return;
}
final previousLocale = init.l10n.localeName;
unawaited(() async {
await initLang(init);
final nextLocale = init.l10n.localeName;
if (previousLocale != nextLocale) {
logger.info(
"[Init] System locale changed in auto mode: $previousLocale -> $nextLocale",
);
}
init.themeCubit?.refresh();
}());
};
resetOldTimeTableCache(init.isar);
resetOldHomeworkCache(init.isar);
var didRunFreshInstallCleanup = false;
if (Platform.isIOS) {
try {
didRunFreshInstallCleanup =
await WatchSyncHelper.runFreshInstallCleanupIfNeeded(isar: init.isar);
if (didRunFreshInstallCleanup) {
logger.info(
'[Init] Fresh-install cleanup completed; skipping startup iCloud recovery on this launch',
);
} else {
await WatchSyncHelper.checkAndRecoverFromiCloud(
isar: init.isar,
tokens: init.tokens,
);
}
} catch (e) {
logger.warning('[Init] iCloud bootstrap/recovery failed: $e');
}
}
final allTokens = await init.isar.tokenModels.where().findAll();
init.tokens = allTokens;
if (allTokens.isNotEmpty) {
final token = pickActiveToken(tokens: allTokens, settings: init.settings);
if (token == null) {
logger.warning(
"[Init] Tokens disappeared during initialization; skipping client setup",
);
return;
}
logger.fine("Initializing kréta client as: ${token.studentId}");
init.client = KretaClient(token, init.isar, init.reauthCubit!);
if (Platform.isIOS) {
final expiryDate = token.expiryDate;
if (expiryDate != null && expiryDate.isAfter(DateTime.now())) {
init.reauthCubit?.clear();
}
unawaited(() async {
try {
await WatchSyncHelper.saveTokenToiCloud(token);
} catch (e) {
logger.warning('[Init] Failed to sync active token to iCloud: $e');
}
try {
await WatchSyncHelper.sendTokenModelToWatch(token);
} catch (e) {
logger.warning('[Init] Failed to sync active token to Watch: $e');
}
}());
}
}
final dataDir = await getApplicationDocumentsDirectory();
var pfpFile = File(p.join(dataDir.path, "profile.webp"));
if (await pfpFile.exists()) {
init.profilePicture = await pfpFile.readAsBytes();
}
}
Future<AppInitialization> initializeApp() async {
if (initDone) {
await _initData(initData);
return initData;
}
final isar = await initDB();
final tokens = await isar.tokenModels.where().findAll();
logger.finest('Token count: ${tokens.length}');
var devInfoFetched = false;
var devInfo = DeviceInfo("SM-A705FN", "11", "30");
try {
if (Platform.isAndroid) {
const channel = MethodChannel("firka.app/main");
final rawInfo = ((await channel.invokeMethod("get_info")) as String)
.split(";");
devInfo = DeviceInfo(rawInfo[0], rawInfo[1], rawInfo[2]);
devInfoFetched = true;
}
} catch (e) {
if (e is Error) {
logger.shout("Error in initializeApp()", e.toString(), e.stackTrace);
} else {
logger.shout("Error in initializeApp()", e.toString());
}
}
logger.fine("Fetched device info: ${devInfoFetched ? "yes" : "no"}");
logger.fine("Using device info: ${devInfo.toString()}");
var init = AppInitialization(
isar: isar,
appDir: await getApplicationDocumentsDirectory(),
devInfo: devInfo,
packageInfo: await PackageInfo.fromPlatform(),
tokens: tokens,
settings: SettingsStore(AppLocalizationsHu()),
l10n: AppLocalizationsHu(),
navigatorKey: navigatorKey,
);
if (Platform.isIOS) {
try {
await LiveActivityService.initialize().timeout(
const Duration(seconds: 8),
);
} on TimeoutException catch (e, st) {
logger.warning('LiveActivity init timed out: $e', e, st);
} catch (e, st) {
logger.severe('Failed to initialize LiveActivity: $e', e, st);
}
}
await _initData(init);
return init;
}
Future<void> setupLogging() async {
final jwtPattern = RegExp(
r'([A-Za-z0-9-_]+)\.([A-Za-z0-9-_]+)\.([A-Za-z0-9-_]+)',
);
final omPattern = RegExp(r'(\d{3})(\d{6})([A-Za-z0-9]?)');
final refreshTokenPattern = RegExp(
r'"(?=.{21,}$)([A-Z0-9]+-[A-Z0-9_\-.~+]*)"',
);
final docs = await getApplicationDocumentsDirectory();
Future<void> deleteOldLogFiles() async {
final docs = await getApplicationDocumentsDirectory();
final dir = Directory(docs.path);
if (!dir.existsSync()) return;
final now = DateTime.now();
final cutoff = now.subtract(Duration(days: 30));
final logFileRegex = RegExp(r'^(\d{4})_(\d{2})_(\d{2})\.log$');
for (final entity in dir.listSync()) {
if (entity is! File) continue;
final name = entity.uri.pathSegments.last;
final m = logFileRegex.firstMatch(name);
if (m == null) continue;
try {
final y = int.parse(m.group(1)!);
final mo = int.parse(m.group(2)!);
final d = int.parse(m.group(3)!);
final fileDate = DateTime(y, mo, d);
if (fileDate.isBefore(
DateTime(cutoff.year, cutoff.month, cutoff.day),
)) {
logger.info("Removing old log file: $name");
await entity.delete();
}
} catch (_) {
// ignore parse/delete errors
}
}
}
String logFilePathForDate(DateTime dt) {
final fileName = "${DateFormat("yyyy_MM_dd").format(dt)}.log";
return Directory(docs.path).uri.resolve(fileName).toFilePath();
}
File fileForDate(DateTime dt) {
final path = logFilePathForDate(dt);
final file = File(path);
if (!file.existsSync()) file.createSync(recursive: true);
return file;
}
String censorLog(String msg) {
return msg
.replaceAll(jwtPattern, '***')
.replaceAllMapped(omPattern, (match) {
return "${match.group(1)}******${match.group(3)}";
})
.replaceAll(refreshTokenPattern, '"***"');
}
hierarchicalLoggingEnabled = true;
logger.level = Level.ALL;
DateTime currentDate = DateTime.now();
IOSink sink = fileForDate(currentDate).openWrite(mode: FileMode.append);
logger.onRecord.listen((record) {
final now = DateTime.now();
if (now.year != currentDate.year ||
now.month != currentDate.month ||
now.day != currentDate.day) {
sink.flush();
sink.close();
currentDate = now;
sink = fileForDate(currentDate).openWrite(mode: FileMode.append);
}
final censored = censorLog(record.message);
final timestamp = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format(now);
final level = record.level.name;
final line = '[$timestamp] [$level] [$censored]';
sink.writeln(line);
debugPrint(
"[Firka] [${record.level.name}] ${kDebugMode ? record.message : censored}",
);
});
unawaited(deleteOldLogFiles());
try {
logger.finest('loading dirty words');
await loadDirtyWords();
logger.finest('loaded dirty words');
} catch (e, st) {
logger.severe('Failed to load dirty words: $e', e, st);
}
}

View File

@@ -1,237 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:firka/app/app_state.dart';
import 'package:firka/app/initialization.dart';
import 'package:firka/core/bloc/home_refresh_cubit.dart';
import 'package:firka/core/settings.dart';
import 'package:firka/core/bloc/profile_picture_cubit.dart';
import 'package:firka/core/bloc/reauth_cubit.dart';
import 'package:firka/core/bloc/settings_cubit.dart';
import 'package:firka/core/bloc/theme_cubit.dart';
import 'package:firka/core/firka_bundle.dart';
import 'package:firka/routing/app_router.dart';
import 'package:firka/services/watch_sync_helper.dart';
import 'package:firka/ui/phone/pages/extras/main_wear_pair.dart';
import 'package:firka/l10n/app_localizations.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:go_router/go_router.dart';
class InitializationScreen extends StatefulWidget {
const InitializationScreen({super.key});
@override
State<InitializationScreen> createState() => _InitializationScreenState();
}
class _InitializationScreenState extends State<InitializationScreen> {
GoRouter? _router;
final Future<AppInitialization> _init = initializeApp().timeout(
const Duration(seconds: 20),
);
@override
Widget build(BuildContext context) {
return FutureBuilder<AppInitialization>(
future: _init,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasError) {
logger.shout(
"Error in InitializationScreen",
snapshot.error.toString(),
snapshot.stackTrace,
);
FlutterNativeSplash.remove();
return MaterialApp(
key: const ValueKey('errorPage'),
home: DefaultAssetBundle(
bundle: FirkaBundle(),
child: Scaffold(
body: Center(
child: Text(
'Error initializing app: ${snapshot.error}',
style: const TextStyle(color: Colors.red),
),
),
),
),
);
}
assert(snapshot.data != null);
initData = snapshot.data!;
initDone = true;
FlutterNativeSplash.remove();
WatchSyncHelper.initialize();
if (Platform.isAndroid) {
WatchSyncHelper.setWearSyncMethodCallHandler();
}
if (Platform.isIOS) {
unawaited(() async {
try {
await WatchSyncHelper.sendLanguageToWatch();
} catch (e) {
logger.warning(
'[Init] Failed to publish language to Watch after sync init: $e',
);
}
}());
}
if (!initData.hasWatchListener) {
initData.hasWatchListener = true;
WatchSyncHelper.onWatchMessage = (msg) {
logger.finest("WatchOS IPC [Watch -> Phone]: ${msg["id"]}");
switch (msg["id"]) {
case "ping":
if (initData.tokens.isNotEmpty) {
logger.finest("WatchOS IPC [Phone -> Watch]: pong");
unawaited(
WatchSyncHelper.sendMessageToWatch({'id': 'pong'}),
);
_router?.go('/home');
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = navigatorKey.currentContext;
if (ctx != null && ctx.mounted) {
logger.info('Watch init_data: ${jsonEncode(msg)}');
showWearBottomSheet(
ctx,
initData,
Platform.isAndroid ? msg['model'] : 'Apple Watch',
);
}
});
}
break;
case "init_done":
case "sync_done":
final ctx = navigatorKey.currentContext;
if (ctx != null && ctx.mounted) {
ScaffoldMessenger.of(ctx).hideCurrentSnackBar();
}
initData.dismissWearPairingSheet?.call();
initData.dismissWearPairingSheet = null;
break;
}
};
if (Platform.isAndroid) {
WatchSyncHelper.watchMessageStream.listen((msg) async {
WatchSyncHelper.onWatchMessage?.call(msg);
if (msg['id'] == 'request_sync' &&
initDone &&
isWearOsSupportEnabled()) {
final ctx = navigatorKey.currentContext;
if (ctx != null && ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(initData.l10n.wear_syncing)),
);
}
await WatchSyncHelper.runWearSyncInForeground(
initData.client,
);
}
});
if (isWearOsSupportEnabled()) {
unawaited(() async {
try {
await WatchSyncHelper.startWearSyncServiceWithFreshCache(
initData.client,
initData.appDir.path,
);
} catch (e) {
logger.warning(
'[Init] Failed to start Wear sync service on launch: $e',
);
}
}());
}
}
}
if (_router == null) {
_router = createAppRouter();
appRouter = _router;
}
final themeCubit = initData.themeCubit!;
final settingsCubit = initData.settingsCubit!;
final profilePictureCubit = initData.profilePictureCubit!;
final reauthCubit = initData.reauthCubit!;
final homeRefreshCubit = initData.homeRefreshCubit!;
return MultiBlocProvider(
providers: [
BlocProvider<ThemeCubit>.value(value: themeCubit),
BlocProvider<SettingsCubit>.value(value: settingsCubit),
BlocProvider<ProfilePictureCubit>.value(
value: profilePictureCubit,
),
BlocProvider<ReauthCubit>.value(value: reauthCubit),
BlocProvider<HomeRefreshCubit>.value(value: homeRefreshCubit),
],
child: MaterialApp.router(
title: 'Firka',
key: const ValueKey('firkaApp'),
routerConfig: _router!,
theme: ThemeData(
primarySwatch: Colors.lightGreen,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
builder: (context, child) {
return BlocBuilder<ThemeCubit, ThemeState>(
builder: (context, themeState) {
final isLight = themeState.isLightMode;
final overlay = SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: isLight
? Brightness.dark
: Brightness.light,
statusBarBrightness: isLight
? Brightness.light
: Brightness.dark,
systemStatusBarContrastEnforced: false,
);
SystemChrome.setSystemUIOverlayStyle(overlay);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: overlay,
child: child ?? const SizedBox.shrink(),
);
},
);
},
),
);
}
return MaterialApp(
home: DefaultAssetBundle(
bundle: FirkaBundle(),
child: Scaffold(
backgroundColor: const Color(0xFF7CA120),
body: Container(),
),
),
);
},
);
}
}

View File

@@ -1,23 +0,0 @@
import 'package:kreta_api/kreta_api.dart';
double calculateAverage(List<Grade> sortedGrades) {
double totalWeight = 0.0;
double weightedSum = 0.0;
for (final grade in sortedGrades) {
final value = grade.numericValue;
final weight = grade.weightPercentage;
if (value != null && weight != null) {
weightedSum += value * weight;
totalWeight += weight;
}
}
if (totalWeight == 0) {
return double.parse(0.0.toStringAsFixed(2));
}
final avg = weightedSum / totalWeight;
return double.parse(avg.toStringAsFixed(2));
}

View File

@@ -1,19 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
class HomeRefreshState {
final int refreshTrigger;
const HomeRefreshState({this.refreshTrigger = 0});
}
class HomeRefreshCubit extends Cubit<HomeRefreshState> {
HomeRefreshCubit() : super(const HomeRefreshState());
void requestRefresh() {
emit(HomeRefreshState(refreshTrigger: state.refreshTrigger + 1));
}
void onRefreshComplete() {
emit(HomeRefreshState(refreshTrigger: state.refreshTrigger));
}
}

View File

@@ -1,15 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
class ProfilePictureState {
final int version;
const ProfilePictureState({this.version = 0});
}
class ProfilePictureCubit extends Cubit<ProfilePictureState> {
ProfilePictureCubit() : super(const ProfilePictureState());
void notifyChanged() {
emit(ProfilePictureState(version: state.version + 1));
}
}

View File

@@ -1,19 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
class ReauthState {
final bool needsReauth;
const ReauthState({this.needsReauth = false});
}
class ReauthCubit extends Cubit<ReauthState> {
ReauthCubit() : super(const ReauthState());
void setNeedsReauth(bool value) {
emit(ReauthState(needsReauth: value));
}
void clear() {
emit(const ReauthState(needsReauth: false));
}
}

View File

@@ -1,15 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
class SettingsState {
final int version;
const SettingsState({this.version = 0});
}
class SettingsCubit extends Cubit<SettingsState> {
SettingsCubit() : super(const SettingsState());
void notifyChanged() {
emit(SettingsState(version: state.version + 1));
}
}

View File

@@ -1,20 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
class ThemeState {
final bool isLightMode;
const ThemeState({required this.isLightMode});
}
class ThemeCubit extends Cubit<ThemeState> {
ThemeCubit({bool initialLightMode = true})
: super(ThemeState(isLightMode: initialLightMode));
void setLightMode(bool isLight) {
emit(ThemeState(isLightMode: isLight));
}
void refresh() {
emit(ThemeState(isLightMode: state.isLightMode));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +0,0 @@
import 'package:flutter/widgets.dart';
abstract class FirkaState<T extends StatefulWidget> extends State<T> {}

View File

@@ -1,68 +0,0 @@
import 'package:isar_community/isar.dart';
import 'package:firka/core/debug_helper.dart';
import 'package:firka/data/util.dart';
part 'homework_cache_model.g.dart';
@collection
class HomeworkCacheModel extends DatedCacheEntry {
HomeworkCacheModel();
}
Future<void> resetOldHomeworkCache(Isar isar) async {
var now = timeNow();
var weeks = await isar.homeworkCacheModels.where().findAll();
var weeksToRemove = List<Id>.empty(growable: true);
for (var week in weeks) {
var date = getDate(week.cacheKey!);
if (date.millisecondsSinceEpoch <
now.subtract(Duration(days: 120)).millisecondsSinceEpoch) {
weeksToRemove.add(week.cacheKey!);
}
}
await isar.writeTxn(() async {
await isar.homeworkCacheModels.deleteAll(weeksToRemove);
});
}
@collection
class HomeworkDoneModel {
Id? id;
late String homeworkId;
late DateTime doneAt;
HomeworkDoneModel();
}
Future<void> markAsDone(Isar isar, String homeWorkUid) async {
await isar.writeTxn(() async {
await isar.homeworkDoneModels.put(
HomeworkDoneModel()
..homeworkId = homeWorkUid
..doneAt = DateTime.now(),
);
});
}
Future<void> markAsNotDone(Isar isar, String homeWorkUid) async {
await isar.writeTxn(() async {
final idsToDelete = await isar.homeworkDoneModels
.filter()
.homeworkIdEqualTo(homeWorkUid)
.idProperty()
.findAll();
await isar.homeworkDoneModels.deleteAll(idsToDelete);
});
}
Future<bool> isHomeworkDone(Isar isar, String homeWorkUid) async {
var existing = await isar.homeworkDoneModels
.filter()
.homeworkIdEqualTo(homeWorkUid)
.findFirst();
return existing != null;
}

View File

@@ -1,4 +1,4 @@
import 'package:firka/data/models/token_model.dart'; import 'db/models/token_model.dart';
int resolveActiveAccountIndex(dynamic settings) { int resolveActiveAccountIndex(dynamic settings) {
try { try {
@@ -8,7 +8,8 @@ int resolveActiveAccountIndex(dynamic settings) {
if (accountIndex is int && accountIndex >= 0) { if (accountIndex is int && accountIndex >= 0) {
return accountIndex; return accountIndex;
} }
} catch (_) {} } catch (_) {
}
return 0; return 0;
} }

View File

@@ -3,20 +3,28 @@ import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:firka/data/models/generic_cache_model.dart'; import 'package:firka/helpers/api/model/all_lessons.dart';
import 'package:firka/data/models/timetable_cache_model.dart'; import 'package:firka/helpers/api/model/class_group.dart';
import 'package:firka/helpers/api/model/homework.dart';
import 'package:firka/helpers/api/model/timetable.dart';
import 'package:firka/helpers/db/models/generic_cache_model.dart';
import 'package:firka/helpers/db/models/timetable_cache_model.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:isar_community/isar.dart'; import 'package:isar/isar.dart';
import 'package:kreta_api/kreta_api.dart' hide KretaEndpoints;
import 'package:firka/app/app_state.dart'; import '../../../main.dart';
import 'package:firka/core/bloc/reauth_cubit.dart'; import '../../db/models/token_model.dart';
import 'package:firka/data/models/token_model.dart'; import '../../db/util.dart';
import 'package:firka/data/util.dart'; import '../../debug_helper.dart';
import 'package:firka/core/debug_helper.dart'; import '../../active_account_helper.dart';
import 'package:firka/services/active_account_helper.dart'; import '../../watch_sync_helper.dart';
import 'package:firka/services/watch_sync_helper.dart';
import '../consts.dart'; import '../consts.dart';
import '../exceptions/token.dart';
import '../model/grade.dart';
import '../model/notice_board.dart';
import '../model/omission.dart';
import '../model/student.dart';
import '../model/test.dart';
import '../token_grant.dart'; import '../token_grant.dart';
import 'dart:io'; import 'dart:io';
@@ -29,30 +37,56 @@ const backoffCount = 4;
const backoffMin = 100; const backoffMin = 100;
const backoffStep = 500; const backoffStep = 500;
class ApiResponse<T> {
T? response;
int statusCode;
String? err;
bool cached;
ApiResponse(
this.response,
this.statusCode,
this.err,
this.cached,
);
@override
String toString() {
return "ApiResponse("
"response: $response, "
"statusCode: $statusCode, "
"err: \"$err\", "
"cached: $cached"
")";
}
}
class KretaClient { class KretaClient {
Completer<void>? _tokenMutexCompleter; Completer<void>? _tokenMutexCompleter;
TokenModel model; TokenModel model;
Isar isar; Isar isar;
final ReauthCubit _reauthCubit;
KretaClient(this.model, this.isar, this._reauthCubit); static bool needsReauth = false;
bool get needsReauth => _reauthCubit.state.needsReauth; static final ValueNotifier<bool> reauthStateNotifier = ValueNotifier(false);
void clearReauthFlag() { static void clearReauthFlag() {
_reauthCubit.clear(); needsReauth = false;
reauthStateNotifier.value = false;
debugPrint('[KretaClient] Reauth flag cleared'); debugPrint('[KretaClient] Reauth flag cleared');
} }
Future<void> _setReauthFlag() async { static Future<void> _setReauthFlag() async {
if (needsReauth) return; if (needsReauth) return;
_reauthCubit.setNeedsReauth(true); needsReauth = true;
reauthStateNotifier.value = true;
debugPrint('[KretaClient] Reauth flag set'); debugPrint('[KretaClient] Reauth flag set');
} }
KretaClient(this.model, this.isar);
Future<TokenModel> _refreshModelWithCrossDeviceLease( Future<TokenModel> _refreshModelWithCrossDeviceLease(
TokenModel sourceToken, TokenModel sourceToken) async {
) async {
final studentIdNorm = sourceToken.studentIdNorm; final studentIdNorm = sourceToken.studentIdNorm;
String? leaseOperationId; String? leaseOperationId;
@@ -78,7 +112,9 @@ class KretaClient {
final extended = await extendToken(sourceToken); final extended = await extendToken(sourceToken);
return TokenModel.fromResp(extended); return TokenModel.fromResp(extended);
} finally { } finally {
if (Platform.isIOS && studentIdNorm != null && leaseOperationId != null) { if (Platform.isIOS &&
studentIdNorm != null &&
leaseOperationId != null) {
await WatchSyncHelper.releaseIPhoneRefreshLease( await WatchSyncHelper.releaseIPhoneRefreshLease(
studentIdNorm: studentIdNorm, studentIdNorm: studentIdNorm,
operationId: leaseOperationId, operationId: leaseOperationId,
@@ -98,8 +134,7 @@ class KretaClient {
final watchInstalled = await WatchSyncHelper.isWatchAppInstalled(); final watchInstalled = await WatchSyncHelper.isWatchAppInstalled();
if (!watchInstalled) { if (!watchInstalled) {
debugPrint( debugPrint(
'[KretaClient] Skipping Apple token sync because no paired Watch app is installed', '[KretaClient] Skipping Apple token sync because no paired Watch app is installed');
);
return; return;
} }
@@ -152,8 +187,7 @@ class KretaClient {
if (localExpiry != null && if (localExpiry != null &&
localExpiry.isAfter(now.add(const Duration(seconds: 60)))) { localExpiry.isAfter(now.add(const Duration(seconds: 60)))) {
logger.info( logger.info(
"[Recovery] Existing token is still valid, skipping recovery steps", "[Recovery] Existing token is still valid, skipping recovery steps");
);
clearReauthFlag(); clearReauthFlag();
return true; return true;
} }
@@ -176,9 +210,8 @@ class KretaClient {
} }
if (!Platform.isIOS || !initDone) { if (!Platform.isIOS || !initDone) {
logger.warning( logger
"[Recovery] Not iOS or not initialized, cannot try iCloud", .warning("[Recovery] Not iOS or not initialized, cannot try iCloud");
);
return false; return false;
} }
@@ -195,14 +228,12 @@ class KretaClient {
break; break;
} }
logger.info( logger.info(
"[Recovery] Waiting ${delay}s before attempt ${attempt + 1}...", "[Recovery] Waiting ${delay}s before attempt ${attempt + 1}...");
);
await Future.delayed(Duration(seconds: delay)); await Future.delayed(Duration(seconds: delay));
} }
logger.info( logger.info(
"[Recovery] iCloud attempt ${attempt + 1}/${retryDelays.length}...", "[Recovery] iCloud attempt ${attempt + 1}/${retryDelays.length}...");
);
final recovered = await WatchSyncHelper.checkAndRecoverFromiCloud( final recovered = await WatchSyncHelper.checkAndRecoverFromiCloud(
isar: isar, isar: isar,
@@ -214,24 +245,20 @@ class KretaClient {
if (recovered) { if (recovered) {
iCloudHasToken = true; iCloudHasToken = true;
await _reloadActiveTokenModel( await _reloadActiveTokenModel(
preferredStudentIdNorm: model.studentIdNorm, preferredStudentIdNorm: model.studentIdNorm);
);
final recoveredExpiry = model.expiryDate; final recoveredExpiry = model.expiryDate;
if (recoveredExpiry != null && if (recoveredExpiry != null &&
recoveredExpiry.isAfter( recoveredExpiry
timeNow().add(const Duration(seconds: 60)), .isAfter(timeNow().add(const Duration(seconds: 60)))) {
)) {
logger.info( logger.info(
"[Recovery] Step 2 SUCCESS on attempt ${attempt + 1}: usable iCloud token applied without immediate refresh", "[Recovery] Step 2 SUCCESS on attempt ${attempt + 1}: usable iCloud token applied without immediate refresh");
);
clearReauthFlag(); clearReauthFlag();
return true; return true;
} }
logger.info( logger.info(
"[Recovery] Found iCloud token close to expiry, trying refresh...", "[Recovery] Found iCloud token close to expiry, trying refresh...");
);
try { try {
var tokenModel = await _refreshModelWithCrossDeviceLease(model); var tokenModel = await _refreshModelWithCrossDeviceLease(model);
@@ -246,14 +273,12 @@ class KretaClient {
return true; return true;
} catch (e) { } catch (e) {
logger.warning( logger.warning(
"[Recovery] iCloud token refresh failed on attempt ${attempt + 1}: $e", "[Recovery] iCloud token refresh failed on attempt ${attempt + 1}: $e");
);
iCloudHasToken = true; iCloudHasToken = true;
} }
} else { } else {
logger.info( logger.info(
"[Recovery] No fresh token in iCloud on attempt ${attempt + 1}", "[Recovery] No fresh token in iCloud on attempt ${attempt + 1}");
);
if (attempt == 0) { if (attempt == 0) {
iCloudHasToken = false; iCloudHasToken = false;
} }
@@ -271,8 +296,7 @@ class KretaClient {
if (model.expiryDate == null || if (model.expiryDate == null ||
model.expiryDate!.isBefore(fiveMinutesFromNow)) { model.expiryDate!.isBefore(fiveMinutesFromNow)) {
logger.info( logger.info(
"[Proactive] Token expired or expiring soon, starting recovery...", "[Proactive] Token expired or expiring soon, starting recovery...");
);
final recovered = await recoverToken(); final recovered = await recoverToken();
if (recovered) { if (recovered) {
@@ -292,8 +316,7 @@ class KretaClient {
} }
logger.fine( logger.fine(
"[Proactive] Token still valid until ${model.expiryDate}, no refresh needed", "[Proactive] Token still valid until ${model.expiryDate}, no refresh needed");
);
return true; return true;
} }
@@ -302,19 +325,15 @@ class KretaClient {
if (_tokenMutexCompleter != null) { if (_tokenMutexCompleter != null) {
try { try {
await _tokenMutexCompleter!.future.timeout( await _tokenMutexCompleter!.future.timeout(maxWaitTime, onTimeout: () {
maxWaitTime, logger.warning(
onTimeout: () { "[Mutex] Timeout waiting for token mutex, forcing release");
logger.warning( if (_tokenMutexCompleter != null && !_tokenMutexCompleter!.isCompleted) {
"[Mutex] Timeout waiting for token mutex, forcing release", _tokenMutexCompleter!.complete();
); }
if (_tokenMutexCompleter != null && });
!_tokenMutexCompleter!.isCompleted) { } catch (_) {
_tokenMutexCompleter!.complete(); }
}
},
);
} catch (_) {}
} }
_tokenMutexCompleter = Completer<void>(); _tokenMutexCompleter = Completer<void>();
@@ -336,8 +355,7 @@ class KretaClient {
if (now.millisecondsSinceEpoch >= if (now.millisecondsSinceEpoch >=
model.expiryDate!.millisecondsSinceEpoch) { model.expiryDate!.millisecondsSinceEpoch) {
logger.info( logger.info(
"Token expired at ${model.expiryDate}, starting recovery for user: ${model.studentId}", "Token expired at ${model.expiryDate}, starting recovery for user: ${model.studentId}");
);
final recovered = await recoverToken(); final recovered = await recoverToken();
if (!recovered) { if (!recovered) {
@@ -354,21 +372,15 @@ class KretaClient {
"accept": "*/*", "accept": "*/*",
"user-agent": Constants.userAgent, "user-agent": Constants.userAgent,
"authorization": "Bearer $localToken", "authorization": "Bearer $localToken",
"apiKey": "21ff6c25-d1da-4a68-a811-c881a6057463", "apiKey": "21ff6c25-d1da-4a68-a811-c881a6057463"
}; };
return await dio.get( return await dio.get(url,
url, options: Options(method: method, headers: headers), data: data);
options: Options(method: method, headers: headers),
data: data,
);
} }
Future<(dynamic, int)> _authJson( Future<(dynamic, int)> _authJson(String method, String url,
String method, [Object? data]) async {
String url, [
Object? data,
]) async {
Response<dynamic> resp; Response<dynamic> resp;
try { try {
@@ -384,17 +396,13 @@ class KretaClient {
(responseData is List && responseData.isEmpty) || (responseData is List && responseData.isEmpty) ||
(responseData is Map && responseData.isEmpty)) { (responseData is Map && responseData.isEmpty)) {
logger.warning( logger.warning(
"API returned ${resp.statusCode} with empty data for: $url - possible stale session", "API returned ${resp.statusCode} with empty data for: $url - possible stale session");
);
} }
} }
} catch (ex) { } catch (ex) {
if (ex is Error) { if (ex is Error) {
logger.shout( logger.shout(
"Request to url: $url failed", "Request to url: $url failed", ex.toString(), ex.stackTrace);
ex.toString(),
ex.stackTrace,
);
} else { } else {
logger.shout("Request to url: $url failed", ex.toString()); logger.shout("Request to url: $url failed", ex.toString());
} }
@@ -406,11 +414,7 @@ class KretaClient {
} }
Future<(dynamic, int, Object?, bool)> _cachingGet( Future<(dynamic, int, Object?, bool)> _cachingGet(
CacheId id, CacheId id, String url, bool forceCache, int counter) async {
String url,
bool forceCache,
int counter,
) async {
// it would be *ideal* to use xor and left shift here, however // it would be *ideal* to use xor and left shift here, however
// binary operations seem to round the number down to // binary operations seem to round the number down to
// 32 bits for some reason??? // 32 bits for some reason???
@@ -422,8 +426,7 @@ class KretaClient {
try { try {
if (forceCache && cache != null) { if (forceCache && cache != null) {
logger.finest( logger.finest(
"_cachingGet(forceCache: $forceCache}): decoding cached response for: $url", "_cachingGet(forceCache: $forceCache}): decoding cached response for: $url");
);
return (jsonDecode(cache.cacheData!), 200, null, true); return (jsonDecode(cache.cacheData!), 200, null, true);
} }
@@ -433,18 +436,14 @@ class KretaClient {
if (statusCode >= 400) { if (statusCode >= 400) {
if (cache != null) { if (cache != null) {
logger.finest( logger.finest(
"_cachingGet(forceCache: $forceCache}): decoding uncached response for: $url", "_cachingGet(forceCache: $forceCache}): decoding uncached response for: $url");
);
return (jsonDecode(cache.cacheData!), statusCode, null, true); return (jsonDecode(cache.cacheData!), statusCode, null, true);
} }
} }
} catch (ex) { } catch (ex) {
if (ex is Error) { if (ex is Error) {
logger.finest( logger.finest(
"Request failed for $url", "Request failed for $url", ex.toString(), ex.stackTrace);
ex.toString(),
ex.stackTrace,
);
} else { } else {
logger.finest("Request failed for $url", ex.toString()); logger.finest("Request failed for $url", ex.toString());
} }
@@ -503,12 +502,8 @@ class KretaClient {
} else if (studentCache != null) { } else if (studentCache != null) {
return studentCache!; return studentCache!;
} }
var (resp, status, ex, cached) = await _cachingGet( var (resp, status, ex, cached) = await _cachingGet(CacheId.getStudent,
CacheId.getStudent, KretaEndpoints.getStudentUrl(model.iss!), forceCache, 0);
KretaEndpoints.getStudentUrl(model.iss!),
forceCache,
0,
);
Student? student; Student? student;
String? err; String? err;
@@ -529,20 +524,15 @@ class KretaClient {
ApiResponse<List<ClassGroup>>? classGroupCache; ApiResponse<List<ClassGroup>>? classGroupCache;
Future<ApiResponse<List<ClassGroup>>> getClassGroups({ Future<ApiResponse<List<ClassGroup>>> getClassGroups(
bool forceCache = true, {bool forceCache = true}) async {
}) async {
if (!forceCache) { if (!forceCache) {
classGroupCache = null; classGroupCache = null;
} else { } else {
if (classGroupCache != null) return classGroupCache!; if (classGroupCache != null) return classGroupCache!;
} }
var (resp, status, ex, cached) = await _cachingGet( var (resp, status, ex, cached) = await _cachingGet(CacheId.getClassGroup,
CacheId.getClassGroup, KretaEndpoints.getClassGroups(model.iss!), forceCache, 0);
KretaEndpoints.getClassGroups(model.iss!),
forceCache,
0,
);
final classGroups = List<ClassGroup>.empty(growable: true); final classGroups = List<ClassGroup>.empty(growable: true);
String? err; String? err;
@@ -566,20 +556,15 @@ class KretaClient {
ApiResponse<List<NoticeBoardItem>>? noticeBoardCache; ApiResponse<List<NoticeBoardItem>>? noticeBoardCache;
Future<ApiResponse<List<NoticeBoardItem>>> getNoticeBoard({ Future<ApiResponse<List<NoticeBoardItem>>> getNoticeBoard(
bool forceCache = true, {bool forceCache = true}) async {
}) async {
if (!forceCache) { if (!forceCache) {
noticeBoardCache = null; noticeBoardCache = null;
} else if (noticeBoardCache != null) { } else if (noticeBoardCache != null) {
return noticeBoardCache!; return noticeBoardCache!;
} }
var (resp, status, ex, cached) = await _cachingGet( var (resp, status, ex, cached) = await _cachingGet(CacheId.getNoticeBoard,
CacheId.getNoticeBoard, KretaEndpoints.getNoticeBoard(model.iss!), forceCache, 0);
KretaEndpoints.getNoticeBoard(model.iss!),
forceCache,
0,
);
var items = List<NoticeBoardItem>.empty(growable: true); var items = List<NoticeBoardItem>.empty(growable: true);
String? err; String? err;
@@ -603,16 +588,11 @@ class KretaClient {
ApiResponse<List<InfoBoardItem>>? infoBoardCache; ApiResponse<List<InfoBoardItem>>? infoBoardCache;
Future<ApiResponse<List<InfoBoardItem>>> getInfoBoard({ Future<ApiResponse<List<InfoBoardItem>>> getInfoBoard(
bool forceCache = true, {bool forceCache = true}) async {
}) async {
if (forceCache && infoBoardCache != null) return infoBoardCache!; if (forceCache && infoBoardCache != null) return infoBoardCache!;
var (resp, status, ex, cached) = await _cachingGet( var (resp, status, ex, cached) = await _cachingGet(CacheId.getInfoBoard,
CacheId.getInfoBoard, KretaEndpoints.getInfoBoard(model.iss!), forceCache, 0);
KretaEndpoints.getInfoBoard(model.iss!),
forceCache,
0,
);
var items = List<InfoBoardItem>.empty(growable: true); var items = List<InfoBoardItem>.empty(growable: true);
String? err; String? err;
@@ -643,11 +623,7 @@ class KretaClient {
return gradeCache!; return gradeCache!;
} }
var (resp, status, ex, cached) = await _cachingGet( var (resp, status, ex, cached) = await _cachingGet(
CacheId.getGrades, CacheId.getGrades, KretaEndpoints.getGrades(model.iss!), forceCache, 0);
KretaEndpoints.getGrades(model.iss!),
forceCache,
0,
);
var items = List<Grade>.empty(growable: true); var items = List<Grade>.empty(growable: true);
String? err; String? err;
@@ -674,19 +650,14 @@ class KretaClient {
ApiResponse<List<SubjectAverage>>? subjectAverageCache; ApiResponse<List<SubjectAverage>>? subjectAverageCache;
Future<ApiResponse<List<SubjectAverage>>> getSubjectAverage( Future<ApiResponse<List<SubjectAverage>>> getSubjectAverage(
ClassGroup classGroup, { ClassGroup classGroup,
bool forceCache = true, {bool forceCache = true}) async {
}) async {
String? err; String? err;
if (classGroup.studyTask == null) { if (classGroup.studyTask == null) {
err = "classGroup.studyTask is null"; err = "classGroup.studyTask is null";
logger.warning(err); logger.warning(err);
return ApiResponse( return ApiResponse(
List<SubjectAverage>.empty(growable: true), List<SubjectAverage>.empty(growable: true), 0, err, false);
0,
err,
false,
);
} }
if (!forceCache) { if (!forceCache) {
subjectAverageCache = null; subjectAverageCache = null;
@@ -694,12 +665,8 @@ class KretaClient {
return subjectAverageCache!; return subjectAverageCache!;
} }
var studyTaskUid = classGroup.studyTask!.uid.toString().split(",").first; var studyTaskUid = classGroup.studyTask!.uid.toString().split(",").first;
var (resp, status, ex, cached) = await _cachingGet( var (resp, status, ex, cached) = await _cachingGet(CacheId.getSubjectAvg,
CacheId.getSubjectAvg, KretaEndpoints.getSubjectAvg(model.iss!, studyTaskUid), forceCache, 0);
KretaEndpoints.getSubjectAvg(model.iss!, studyTaskUid),
forceCache,
0,
);
var items = List<SubjectAverage>.empty(growable: true); var items = List<SubjectAverage>.empty(growable: true);
try { try {
@@ -720,15 +687,14 @@ class KretaClient {
} }
Future<(List<dynamic>, int, Object?, bool)> Future<(List<dynamic>, int, Object?, bool)>
_timedCachingGet<T extends DatedCacheEntry>( _timedCachingGet<T extends DatedCacheEntry>(
IsarCollection<T> cacheModel, IsarCollection<T> cacheModel,
String endpoint, String endpoint,
DateTime from, DateTime from,
DateTime? to, DateTime? to,
bool forceCache, bool forceCache,
int counter, int counter,
Future<void> Function(dynamic, int) storeCache, Future<void> Function(dynamic, int) storeCache) async {
) async {
var cacheKey = genCacheKey(from, model.studentIdNorm!); var cacheKey = genCacheKey(from, model.studentIdNorm!);
var cache = await cacheModel.get(cacheKey); var cache = await cacheModel.get(cacheKey);
var formatter = DateFormat('yyyy-MM-dd'); var formatter = DateFormat('yyyy-MM-dd');
@@ -754,16 +720,14 @@ class KretaClient {
try { try {
if (toStr == null) { if (toStr == null) {
(resp, statusCode) = await _authJson( (resp, statusCode) = await _authJson(
"GET", "GET",
"$endpoint?" "$endpoint?"
"datumTol=$fromStr", "datumTol=$fromStr");
);
} else { } else {
(resp, statusCode) = await _authJson( (resp, statusCode) = await _authJson(
"GET", "GET",
"$endpoint?" "$endpoint?"
"datumTol=$fromStr&datumIg=$toStr", "datumTol=$fromStr&datumIg=$toStr");
);
} }
if (statusCode >= 400) { if (statusCode >= 400) {
@@ -783,25 +747,16 @@ class KretaClient {
} }
await Future.delayed( await Future.delayed(
Duration(milliseconds: backoffMin + (counter * backoffStep)), Duration(milliseconds: backoffMin + (counter * backoffStep)));
);
return _timedCachingGet( return _timedCachingGet(cacheModel, endpoint, from, to, forceCache,
cacheModel, counter + 1, storeCache);
endpoint,
from,
to,
forceCache,
counter + 1,
storeCache,
);
} }
} catch (ex) { } catch (ex) {
if (_isTokenExpired(ex)) { if (_isTokenExpired(ex)) {
await _setReauthFlag(); await _setReauthFlag();
logger.warning( logger.warning(
"Token expired in timed request, setting needsReauth flag", "Token expired in timed request, setting needsReauth flag");
);
} }
if (cache != null) { if (cache != null) {
@@ -832,36 +787,27 @@ class KretaClient {
/// Expects from and to to be 7 days apart /// Expects from and to to be 7 days apart
Future<ApiResponse<List<Lesson>>> _getTimeTable( Future<ApiResponse<List<Lesson>>> _getTimeTable(
DateTime from, DateTime from, DateTime to, bool forceCache) async {
DateTime to, var (resp, status, ex, cached) =
bool forceCache, await _timedCachingGet<TimetableCacheModel>(
) async { isar.timetableCacheModels,
var ( KretaEndpoints.getTimeTable(model.iss!),
resp, from,
status, to,
ex, forceCache,
cached, 0, (dynamic resp, int cacheKey) async {
) = await _timedCachingGet<TimetableCacheModel>( TimetableCacheModel cache = TimetableCacheModel();
isar.timetableCacheModels, var rawClasses = List<String>.empty(growable: true);
KretaEndpoints.getTimeTable(model.iss!),
from,
to,
forceCache,
0,
(dynamic resp, int cacheKey) async {
TimetableCacheModel cache = TimetableCacheModel();
var rawClasses = List<String>.empty(growable: true);
for (var obj in resp) { for (var obj in resp) {
rawClasses.add(jsonEncode(obj)); rawClasses.add(jsonEncode(obj));
} }
cache.cacheKey = cacheKey; cache.cacheKey = cacheKey;
cache.values = rawClasses; cache.values = rawClasses;
await isar.timetableCacheModels.put(cache as dynamic); await isar.timetableCacheModels.put(cache as dynamic);
}, });
);
var items = List<Lesson>.empty(growable: true); var items = List<Lesson>.empty(growable: true);
String? err; String? err;
@@ -881,18 +827,16 @@ class KretaClient {
return ApiResponse(items, status, err, cached); return ApiResponse(items, status, err, cached);
} }
Future<ApiResponse<List<Homework>>> getHomework({ Future<ApiResponse<List<Homework>>> getHomework(
bool forceCache = true, {bool forceCache = true}) async {
}) async {
final now = timeNow().subtract(Duration(days: 365)); final now = timeNow().subtract(Duration(days: 365));
var formatter = DateFormat('yyyy-MM-dd'); var formatter = DateFormat('yyyy-MM-dd');
var start = formatter.format(now); var start = formatter.format(now);
var (resp, status, ex, cached) = await _cachingGet( var (resp, status, ex, cached) = await _cachingGet(
CacheId.getHomework, CacheId.getHomework,
"${KretaEndpoints.getHomework(model.iss!)}?datumTol=$start", "${KretaEndpoints.getHomework(model.iss!)}?datumTol=$start",
forceCache, forceCache,
0, 0);
);
var items = List<Homework>.empty(growable: true); var items = List<Homework>.empty(growable: true);
String? err; String? err;
@@ -915,20 +859,15 @@ class KretaClient {
} }
/// Automatically aligns requests to start at Monday and end at Sunday /// Automatically aligns requests to start at Monday and end at Sunday
Future<ApiResponse<List<Lesson>>> getTimeTable( Future<ApiResponse<List<Lesson>>> getTimeTable(DateTime from, DateTime to,
DateTime from, {bool forceCache = true}) async {
DateTime to, {
bool forceCache = true,
}) async {
var lessons = List<Lesson>.empty(growable: true); var lessons = List<Lesson>.empty(growable: true);
String? err; String? err;
bool cached = true; bool cached = true;
for ( for (var i = from.millisecondsSinceEpoch;
var i = from.millisecondsSinceEpoch; i < to.millisecondsSinceEpoch;
i < to.millisecondsSinceEpoch; i += 604800000) {
i += 604800000
) {
var from = DateTime.fromMillisecondsSinceEpoch(i); var from = DateTime.fromMillisecondsSinceEpoch(i);
var start = from.subtract(Duration(days: from.weekday - 1)); var start = from.subtract(Duration(days: from.weekday - 1));
var end = start.add(Duration(days: 6)); var end = start.add(Duration(days: 6));
@@ -950,16 +889,14 @@ class KretaClient {
lessons.sort((a, b) => a.start.compareTo(b.start)); lessons.sort((a, b) => a.start.compareTo(b.start));
lessons = lessons lessons = lessons
.where( .where(
(lesson) => lesson.start.isAfter(from) && lesson.end.isBefore(to), (lesson) => lesson.start.isAfter(from) && lesson.end.isBefore(to))
)
.toList(); .toList();
return ApiResponse(lessons, 200, err, cached); return ApiResponse(lessons, 200, err, cached);
} }
Future<ApiResponse<List<AllLessons>>> getLessons({ Future<ApiResponse<List<AllLessons>>> getLessons(
bool forceCache = true, {bool forceCache = true}) async {
}) async {
var (resp, status, ex, cached) = await _cachingGet( var (resp, status, ex, cached) = await _cachingGet(
CacheId.getLessons, CacheId.getLessons,
KretaEndpoints.getLessons(model.iss!), KretaEndpoints.getLessons(model.iss!),
@@ -996,11 +933,7 @@ class KretaClient {
Future<ApiResponse<List<Test>>> getTests({bool forceCache = true}) async { Future<ApiResponse<List<Test>>> getTests({bool forceCache = true}) async {
var (resp, status, ex, cached) = await _cachingGet( var (resp, status, ex, cached) = await _cachingGet(
CacheId.getTests, CacheId.getTests, KretaEndpoints.getTests(model.iss!), forceCache, 0);
KretaEndpoints.getTests(model.iss!),
forceCache,
0,
);
var items = List<Test>.empty(growable: true); var items = List<Test>.empty(growable: true);
String? err; String? err;
@@ -1024,20 +957,15 @@ class KretaClient {
ApiResponse<List<Omission>>? omissionsCache; ApiResponse<List<Omission>>? omissionsCache;
Future<ApiResponse<List<Omission>>> getOmissions({ Future<ApiResponse<List<Omission>>> getOmissions(
bool forceCache = true, {bool forceCache = true}) async {
}) async {
if (!forceCache) { if (!forceCache) {
omissionsCache = null; omissionsCache = null;
} else { } else {
if (omissionsCache != null) return omissionsCache!; if (omissionsCache != null) return omissionsCache!;
} }
var (resp, status, ex, cached) = await _cachingGet( var (resp, status, ex, cached) = await _cachingGet(CacheId.getOmissions,
CacheId.getOmissions, KretaEndpoints.getOmissions(model.iss!), forceCache, 0);
KretaEndpoints.getOmissions(model.iss!),
forceCache,
0,
);
var items = List<Omission>.empty(growable: true); var items = List<Omission>.empty(growable: true);
String? err; String? err;

View File

@@ -1,5 +1,12 @@
import 'package:kreta_api/kreta_api.dart'; import 'package:firka/helpers/api/model/class_group.dart';
import 'package:firka/helpers/api/model/homework.dart';
import 'package:firka/helpers/api/model/notice_board.dart';
import 'package:firka/helpers/api/model/omission.dart';
import 'package:firka/helpers/api/model/test.dart';
import 'package:firka/helpers/api/model/timetable.dart';
import '../model/grade.dart';
import '../model/student.dart';
import 'kreta_client.dart'; import 'kreta_client.dart';
bool getStudentFL = false; bool getStudentFL = false;
@@ -14,9 +21,8 @@ bool getTestsStreamFL = false;
bool getOmissionsStreamFL = false; bool getOmissionsStreamFL = false;
extension KretaStream on KretaClient { extension KretaStream on KretaClient {
Stream<ApiResponse<Student>> getStudentStream({ Stream<ApiResponse<Student>> getStudentStream(
bool cacheOnly = true, {bool cacheOnly = true}) async* {
}) async* {
while (getStudentFL) { while (getStudentFL) {
await Future.delayed(Duration(milliseconds: 10)); await Future.delayed(Duration(milliseconds: 10));
} }
@@ -29,9 +35,8 @@ extension KretaStream on KretaClient {
getStudentFL = false; getStudentFL = false;
} }
Stream<ApiResponse<List<ClassGroup>>> getClassGroupsStream({ Stream<ApiResponse<List<ClassGroup>>> getClassGroupsStream(
bool cacheOnly = true, {bool cacheOnly = true}) async* {
}) async* {
while (getClassGroupsFL) { while (getClassGroupsFL) {
await Future.delayed(Duration(milliseconds: 10)); await Future.delayed(Duration(milliseconds: 10));
} }
@@ -44,9 +49,8 @@ extension KretaStream on KretaClient {
getClassGroupsFL = false; getClassGroupsFL = false;
} }
Stream<ApiResponse<List<NoticeBoardItem>>> getNoticeBoardStream({ Stream<ApiResponse<List<NoticeBoardItem>>> getNoticeBoardStream(
bool cacheOnly = true, {bool cacheOnly = true}) async* {
}) async* {
while (getNoticeBoardStreamFL) { while (getNoticeBoardStreamFL) {
await Future.delayed(Duration(milliseconds: 10)); await Future.delayed(Duration(milliseconds: 10));
} }
@@ -59,9 +63,8 @@ extension KretaStream on KretaClient {
getNoticeBoardStreamFL = false; getNoticeBoardStreamFL = false;
} }
Stream<ApiResponse<List<InfoBoardItem>>> getInfoBoardStream({ Stream<ApiResponse<List<InfoBoardItem>>> getInfoBoardStream(
bool cacheOnly = true, {bool cacheOnly = true}) async* {
}) async* {
while (getInfoBoardStreamFL) { while (getInfoBoardStreamFL) {
await Future.delayed(Duration(milliseconds: 10)); await Future.delayed(Duration(milliseconds: 10));
} }
@@ -74,9 +77,8 @@ extension KretaStream on KretaClient {
getInfoBoardStreamFL = false; getInfoBoardStreamFL = false;
} }
Stream<ApiResponse<List<Grade>>> getGradesStream({ Stream<ApiResponse<List<Grade>>> getGradesStream(
bool cacheOnly = true, {bool cacheOnly = true}) async* {
}) async* {
while (getGradesStreamFL) { while (getGradesStreamFL) {
await Future.delayed(Duration(milliseconds: 10)); await Future.delayed(Duration(milliseconds: 10));
} }
@@ -90,9 +92,8 @@ extension KretaStream on KretaClient {
} }
Stream<ApiResponse<List<SubjectAverage>>> getSubjectAverageStream( Stream<ApiResponse<List<SubjectAverage>>> getSubjectAverageStream(
ClassGroup classGroup, { ClassGroup classGroup,
bool cacheOnly = true, {bool cacheOnly = true}) async* {
}) async* {
while (getSubjectAverageStreamFL) { while (getSubjectAverageStreamFL) {
await Future.delayed(Duration(milliseconds: 10)); await Future.delayed(Duration(milliseconds: 10));
} }
@@ -105,9 +106,8 @@ extension KretaStream on KretaClient {
getSubjectAverageStreamFL = false; getSubjectAverageStreamFL = false;
} }
Stream<ApiResponse<List<Homework>>> getHomeworkStream({ Stream<ApiResponse<List<Homework>>> getHomeworkStream(
bool cacheOnly = true, {bool cacheOnly = true}) async* {
}) async* {
while (getHomeworkStreamFL) { while (getHomeworkStreamFL) {
await Future.delayed(Duration(milliseconds: 10)); await Future.delayed(Duration(milliseconds: 10));
} }
@@ -121,10 +121,8 @@ extension KretaStream on KretaClient {
} }
Stream<ApiResponse<List<Lesson>>> getTimeTableStream( Stream<ApiResponse<List<Lesson>>> getTimeTableStream(
DateTime from, DateTime from, DateTime to,
DateTime to, { {bool cacheOnly = true}) async* {
bool cacheOnly = true,
}) async* {
while (getTimeTableStreamFL) { while (getTimeTableStreamFL) {
await Future.delayed(Duration(milliseconds: 10)); await Future.delayed(Duration(milliseconds: 10));
} }
@@ -137,9 +135,8 @@ extension KretaStream on KretaClient {
getTimeTableStreamFL = false; getTimeTableStreamFL = false;
} }
Stream<ApiResponse<List<Test>>> getTestsStream({ Stream<ApiResponse<List<Test>>> getTestsStream(
bool cacheOnly = true, {bool cacheOnly = true}) async* {
}) async* {
while (getTestsStreamFL) { while (getTestsStreamFL) {
await Future.delayed(Duration(milliseconds: 10)); await Future.delayed(Duration(milliseconds: 10));
} }
@@ -152,9 +149,8 @@ extension KretaStream on KretaClient {
getTestsStreamFL = false; getTestsStreamFL = false;
} }
Stream<ApiResponse<List<Omission>>> getOmissionsStream({ Stream<ApiResponse<List<Omission>>> getOmissionsStream(
bool cacheOnly = true, {bool cacheOnly = true}) async* {
}) async* {
while (getOmissionsStreamFL) { while (getOmissionsStreamFL) {
await Future.delayed(Duration(milliseconds: 10)); await Future.delayed(Duration(milliseconds: 10));
} }

View File

@@ -1,5 +1,5 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:kreta_api/kreta_api.dart'; import 'package:firka/helpers/api/model/timetable.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
@@ -55,8 +55,7 @@ class LiveActivityBackendClient {
'roomName': lesson.roomName, 'roomName': lesson.roomName,
'isSubstitution': lesson.substituteTeacher != null, 'isSubstitution': lesson.substituteTeacher != null,
'substituteTeacher': lesson.substituteTeacher, 'substituteTeacher': lesson.substituteTeacher,
'isCancelled': 'isCancelled': lesson.state.name?.toLowerCase().contains('elmarad') ?? false,
lesson.state.name?.toLowerCase().contains('elmarad') ?? false,
'lastModified': validLastModified.toIso8601String(), 'lastModified': validLastModified.toIso8601String(),
}; };
}).toList(); }).toList();
@@ -87,9 +86,7 @@ class LiveActivityBackendClient {
requestData['liveActivityEnabled'] = liveActivityEnabled; requestData['liveActivityEnabled'] = liveActivityEnabled;
} }
_logger.info( _logger.info('Registering device with backend. Sending ${lessonsData.length} lessons.');
'Registering device with backend. Sending ${lessonsData.length} lessons.',
);
if (_logger.isLoggable(Level.FINE)) { if (_logger.isLoggable(Level.FINE)) {
for (var lesson in lessonsData) { for (var lesson in lessonsData) {
_logger.fine(' Lesson data: $lesson'); _logger.fine(' Lesson data: $lesson');
@@ -102,9 +99,7 @@ class LiveActivityBackendClient {
); );
if (response.statusCode == 200 || response.statusCode == 201) { if (response.statusCode == 200 || response.statusCode == 201) {
_logger.info( _logger.info('Device registered successfully with ${timetable.length} lessons');
'Device registered successfully with ${timetable.length} lessons',
);
return true; return true;
} }
@@ -141,15 +136,12 @@ class LiveActivityBackendClient {
'roomName': lesson.roomName, 'roomName': lesson.roomName,
'isSubstitution': lesson.substituteTeacher != null, 'isSubstitution': lesson.substituteTeacher != null,
'substituteTeacher': lesson.substituteTeacher, 'substituteTeacher': lesson.substituteTeacher,
'isCancelled': 'isCancelled': lesson.state.name?.toLowerCase().contains('elmarad') ?? false,
lesson.state.name?.toLowerCase().contains('elmarad') ?? false,
'lastModified': validLastModified.toIso8601String(), 'lastModified': validLastModified.toIso8601String(),
}; };
}).toList(); }).toList();
_logger.info( _logger.info('Updating timetable with backend. Sending ${lessonsData.length} lessons.');
'Updating timetable with backend. Sending ${lessonsData.length} lessons.',
);
if (_logger.isLoggable(Level.FINE)) { if (_logger.isLoggable(Level.FINE)) {
for (var lesson in lessonsData) { for (var lesson in lessonsData) {
_logger.fine(' Lesson data: $lesson'); _logger.fine(' Lesson data: $lesson');
@@ -185,11 +177,15 @@ class LiveActivityBackendClient {
} }
/// Unregister device (called when user logs out) /// Unregister device (called when user logs out)
Future<bool> unregisterDevice({required String deviceToken}) async { Future<bool> unregisterDevice({
required String deviceToken,
}) async {
try { try {
final response = await _dio.delete( final response = await _dio.delete(
'/live-activity/unregister', '/live-activity/unregister',
data: {'deviceToken': deviceToken}, data: {
'deviceToken': deviceToken,
},
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
@@ -232,11 +228,15 @@ class LiveActivityBackendClient {
} }
/// Get current timetable from backend /// Get current timetable from backend
Future<List<Lesson>?> getTimetable({required String deviceToken}) async { Future<List<Lesson>?> getTimetable({
required String deviceToken,
}) async {
try { try {
final response = await _dio.get( final response = await _dio.get(
'/live-activity/timetable', '/live-activity/timetable',
queryParameters: {'deviceToken': deviceToken}, queryParameters: {
'deviceToken': deviceToken,
},
); );
if (response.statusCode == 200 && response.data is Map) { if (response.statusCode == 200 && response.data is Map) {
@@ -261,7 +261,10 @@ class LiveActivityBackendClient {
try { try {
final response = await _dio.post( final response = await _dio.post(
'/live-activity/push-token', '/live-activity/push-token',
data: {'deviceToken': deviceToken, 'pushToken': pushToken}, data: {
'deviceToken': deviceToken,
'pushToken': pushToken,
},
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
@@ -285,7 +288,10 @@ class LiveActivityBackendClient {
try { try {
final response = await _dio.post( final response = await _dio.post(
'/live-activity/apns-token', '/live-activity/apns-token',
data: {'deviceToken': deviceToken, 'apnsPushToken': apnsPushToken}, data: {
'deviceToken': deviceToken,
'apnsPushToken': apnsPushToken,
},
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
@@ -293,9 +299,7 @@ class LiveActivityBackendClient {
return true; return true;
} }
_logger.warning( _logger.warning('Failed to update APNs push token: ${response.statusCode}');
'Failed to update APNs push token: ${response.statusCode}',
);
return false; return false;
} catch (e) { } catch (e) {
_logger.severe('Error updating APNs push token: $e'); _logger.severe('Error updating APNs push token: $e');
@@ -304,11 +308,15 @@ class LiveActivityBackendClient {
} }
/// Send a test notification (for debugging) /// Send a test notification (for debugging)
Future<bool> sendTestNotification({required String deviceToken}) async { Future<bool> sendTestNotification({
required String deviceToken,
}) async {
try { try {
final response = await _dio.post( final response = await _dio.post(
'/live-activity/test-notification', '/live-activity/test-notification',
data: {'deviceToken': deviceToken}, data: {
'deviceToken': deviceToken,
},
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
@@ -316,9 +324,7 @@ class LiveActivityBackendClient {
return true; return true;
} }
_logger.warning( _logger.warning('Failed to send test notification: ${response.statusCode}');
'Failed to send test notification: ${response.statusCode}',
);
return false; return false;
} catch (e) { } catch (e) {
_logger.severe('Error sending test notification: $e'); _logger.severe('Error sending test notification: $e');
@@ -334,7 +340,10 @@ class LiveActivityBackendClient {
try { try {
final response = await _dio.put( final response = await _dio.put(
'/live-activity/language', '/live-activity/language',
data: {'deviceToken': deviceToken, 'language': language}, data: {
'deviceToken': deviceToken,
'language': language,
},
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
@@ -358,7 +367,10 @@ class LiveActivityBackendClient {
try { try {
final response = await _dio.put( final response = await _dio.put(
'/live-activity/bell-delay', '/live-activity/bell-delay',
data: {'deviceToken': deviceToken, 'bellDelay': bellDelay}, data: {
'deviceToken': deviceToken,
'bellDelay': bellDelay,
},
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
@@ -385,25 +397,17 @@ class LiveActivityBackendClient {
'/live-activity/morning-notification', '/live-activity/morning-notification',
data: { data: {
'deviceToken': deviceToken, 'deviceToken': deviceToken,
...?(morningNotificationTime != null if (morningNotificationTime != null) 'morningNotificationTime': morningNotificationTime,
? {'morningNotificationTime': morningNotificationTime} if (morningNotificationEnabled != null) 'morningNotificationEnabled': morningNotificationEnabled,
: null),
...?(morningNotificationEnabled != null
? {'morningNotificationEnabled': morningNotificationEnabled}
: null),
}, },
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
_logger.info( _logger.info('Morning notification settings updated successfully: enabled=$morningNotificationEnabled, time=$morningNotificationTime');
'Morning notification settings updated successfully: enabled=$morningNotificationEnabled, time=$morningNotificationTime',
);
return true; return true;
} }
_logger.warning( _logger.warning('Failed to update morning notification settings: ${response.statusCode}');
'Failed to update morning notification settings: ${response.statusCode}',
);
return false; return false;
} catch (e) { } catch (e) {
_logger.severe('Error updating morning notification settings: $e'); _logger.severe('Error updating morning notification settings: $e');
@@ -426,9 +430,7 @@ class LiveActivityBackendClient {
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
_logger.info( _logger.info('Live Activity ${liveActivityEnabled ? "enabled" : "disabled"} successfully');
'Live Activity ${liveActivityEnabled ? "enabled" : "disabled"} successfully',
);
return true; return true;
} }
@@ -440,3 +442,4 @@ class LiveActivityBackendClient {
} }
} }
} }

View File

@@ -4,8 +4,7 @@ import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:firka/app/app_state.dart'; import 'package:firka/main.dart';
import 'package:kreta_api/kreta_api.dart' as ka;
class Constants { class Constants {
static String get clientId { static String get clientId {
@@ -42,6 +41,8 @@ class TimetableConsts {
} }
class KretaEndpoints { class KretaEndpoints {
static String kretaBase = "e-kreta.hu";
static String _generateCodeVerifier() { static String _generateCodeVerifier() {
var random = Random.secure(); var random = Random.secure();
final bytes = List<int>.generate(32, (i) => random.nextInt(256)); final bytes = List<int>.generate(32, (i) => random.nextInt(256));
@@ -63,7 +64,13 @@ class KretaEndpoints {
return base64Url.encode(bytes).replaceAll('=', ''); return base64Url.encode(bytes).replaceAll('=', '');
} }
static String kreta(String iss) => ka.KretaEndpoints.kreta(iss); static String kreta(String iss) {
if (iss == "firka-test") {
return kretaBase;
} else {
return "https://$iss.$kretaBase";
}
}
static final String codeVerifier = _generateCodeVerifier(); static final String codeVerifier = _generateCodeVerifier();
static final String _codeChallenge = _generateCodeChallenge(codeVerifier); static final String _codeChallenge = _generateCodeChallenge(codeVerifier);
@@ -79,28 +86,38 @@ class KretaEndpoints {
static String tokenGrantUrl = "$kretaIdp/connect/token"; static String tokenGrantUrl = "$kretaIdp/connect/token";
static String getStudentUrl(String iss) => static String getStudentUrl(String iss) =>
ka.KretaEndpoints.getStudentUrl(iss); "${kreta(iss)}/ellenorzo/v3/sajat/TanuloAdatlap";
static String getClassGroups(String iss) => static String getClassGroups(String iss) =>
ka.KretaEndpoints.getClassGroups(iss); "${kreta(iss)}/ellenorzo/v3/sajat/OsztalyCsoportok";
static String getNoticeBoard(String iss) => static String getNoticeBoard(String iss) =>
ka.KretaEndpoints.getNoticeBoard(iss); "${kreta(iss)}/ellenorzo/v3/sajat/FaliujsagElemek";
static String getInfoBoard(String iss) => ka.KretaEndpoints.getInfoBoard(iss); // for some reason the [redacted] devs decided to make
// two different apis to get items for the notice board
// that appears on the home screen, like wtf
static String getInfoBoard(String iss) =>
"${kreta(iss)}/ellenorzo/v3/sajat/Feljegyzesek";
static String getGrades(String iss) => ka.KretaEndpoints.getGrades(iss); static String getGrades(String iss) =>
"${kreta(iss)}/ellenorzo/v3/sajat/Ertekelesek";
static String getSubjectAvg(String iss, String studyGroupId) => static String getSubjectAvg(String iss, String studyGroupId) =>
ka.KretaEndpoints.getSubjectAvg(iss, studyGroupId); "${kreta(iss)}/ellenorzo/v3/sajat/Ertekelesek/Atlagok/TantargyiAtlagok?oktatasiNevelesiFeladatUid=$studyGroupId&oktatasiNevelesiFeladatUid=$studyGroupId";
static String getTimeTable(String iss) => ka.KretaEndpoints.getTimeTable(iss); static String getTimeTable(String iss) =>
"${kreta(iss)}/ellenorzo/v3/sajat/OrarendElemek";
static String getOmissions(String iss) => ka.KretaEndpoints.getOmissions(iss); static String getOmissions(String iss) =>
"${kreta(iss)}/ellenorzo/v3/sajat/Mulasztasok";
static String getHomework(String iss) => ka.KretaEndpoints.getHomework(iss); static String getHomework(String iss) =>
"${kreta(iss)}/ellenorzo/v3/sajat/HaziFeladatok";
static String getTests(String iss) => ka.KretaEndpoints.getTests(iss); static String getTests(String iss) =>
"${kreta(iss)}/ellenorzo/v3/sajat/BejelentettSzamonkeresek";
static String getLessons(String iss) => ka.KretaEndpoints.getLessons(iss);
} static String getLessons(String iss) =>
"${kreta(iss)}/dktapi/intezmenyek/munkaterek/tanulok";
}

View File

@@ -0,0 +1,132 @@
import 'dart:convert';
class AllLessons {
final String schoolId;
final String yearId;
final dynamic classId;
final String? className;
final bool classWorkspace;
final dynamic groupId;
final String? groupName;
final bool groupWorkspace;
final String groupWorkspaceName;
final dynamic subjectId;
final String subjectName;
final dynamic teacherId;
final String teacherGuid;
final String teacherName;
final dynamic teacherAnnoId;
final dynamic annoId;
final String? languageId;
final dynamic subjectCategoryId;
final String subjectCategoryName;
final dynamic typeId;
final String typeName;
final dynamic gradeTypeId;
final String gradeTypeName;
final dynamic taskPlaceId;
final String taskPlaceName;
final dynamic teacherAvatarTypeId;
final String teacherAvatarTypePath;
final dynamic taskGroupId;
AllLessons({
required this.schoolId,
required this.yearId,
this.classId,
this.className,
required this.classWorkspace,
this.groupId,
this.groupName,
required this.groupWorkspace,
required this.groupWorkspaceName,
required this.subjectId,
required this.subjectName,
required this.teacherId,
required this.teacherGuid,
required this.teacherName,
this.teacherAnnoId,
this.annoId,
this.languageId,
required this.subjectCategoryId,
required this.subjectCategoryName,
required this.typeId,
required this.typeName,
required this.gradeTypeId,
required this.gradeTypeName,
required this.taskPlaceId,
required this.taskPlaceName,
required this.teacherAvatarTypeId,
required this.teacherAvatarTypePath,
this.taskGroupId,
});
factory AllLessons.fromJson(Map<String, dynamic> json) => AllLessons(
schoolId: json['intezmenyId']?.toString() ?? '',
yearId: json['tanevId']?.toString() ?? '',
classId: json['osztalyId'],
className: json['osztalyNev']?.toString(),
classWorkspace: json['osztalyMunkaTer'] == true,
groupId: json['csoportId'],
groupName: json['csoportNev']?.toString(),
groupWorkspace: json['csoportMunkaTer'] == true,
groupWorkspaceName: json['osztalyCsoportNev']?.toString() ?? '',
subjectId: json['tantargyId'],
subjectName: json['tantargyNev']?.toString() ?? '',
teacherId: json['alkalmazottId'],
teacherGuid: json['alkalmazottGuid']?.toString() ?? '',
teacherName: json['alkalmazottNev']?.toString() ?? '',
teacherAnnoId: json['alkalmazottUzenoFalId'],
annoId: json['uzenoFalId'],
languageId: json['nyelvId']?.toString(),
subjectCategoryId: json['tantargyKategoriaId'],
subjectCategoryName: json['tantargyKategoriaNev']?.toString() ?? '',
typeId: json['tipusId'],
typeName: json['tipusNev']?.toString() ?? '',
gradeTypeId: json['evfolyamTipusId'],
gradeTypeName: json['evfolyamTipusNev']?.toString() ?? '',
taskPlaceId: json['feladatEllatasiHelyId'],
taskPlaceName: json['feladatEllatasiHelyNev']?.toString() ?? '',
teacherAvatarTypeId: json['alkalmazottAvatarTipusId'],
teacherAvatarTypePath:
json['alkalmazottAvatarEleres']?.toString() ?? '',
taskGroupId: json['oraiFeladatGroupId'],
);
Map<String, dynamic> toJson() => {
'intezmenyId': schoolId,
'tanevId': yearId,
'osztalyId': classId,
'osztalyNev': className,
'osztalyMunkaTer': classWorkspace,
'csoportId': groupId,
'csoportNev': groupName,
'csoportMunkaTer': groupWorkspace,
'osztalyCsoportNev': groupWorkspaceName,
'tantargyId': subjectId,
'tantargyNev': subjectName,
'alkalmazottId': teacherId,
'alkalmazottGuid': teacherGuid,
'alkalmazottNev': teacherName,
'alkalmazottUzenoFalId': teacherAnnoId,
'uzenoFalId': annoId,
'nyelvId': languageId,
'tantargyKategoriaId': subjectCategoryId,
'tantargyKategoriaNev': subjectCategoryName,
'tipusId': typeId,
'tipusNev': typeName,
'evfolyamTipusId': gradeTypeId,
'evfolyamTipusNev': gradeTypeName,
'feladatEllatasiHelyId': taskPlaceId,
'feladatEllatasiHelyNev': taskPlaceName,
'alkalmazottAvatarTipusId': teacherAvatarTypeId,
'alkalmazottAvatarEleres': teacherAvatarTypePath,
'oraiFeladatGroupId': taskGroupId,
};
}
List<AllLessons> lessonsFromJson(String str) =>
List<AllLessons>.from(json.decode(str).map((x) => AllLessons.fromJson(x)));
String lessonsToJson(List<AllLessons> data) =>
json.encode(List<dynamic>.from(data.map((x) => x.toJson())));

View File

@@ -1,4 +1,4 @@
import 'generic.dart'; import 'package:firka/helpers/api/model/generic.dart';
class ClassGroup { class ClassGroup {
final String uid; final String uid;
@@ -11,36 +11,34 @@ class ClassGroup {
final bool isActive; final bool isActive;
final String type; final String type;
ClassGroup({ ClassGroup(
required this.uid, {required this.uid,
required this.name, required this.name,
required this.headTeacher, required this.headTeacher,
required this.substituteHeadTeacher, required this.substituteHeadTeacher,
required this.studyGroup, required this.studyGroup,
required this.studyGroupSortIndex, required this.studyGroupSortIndex,
required this.studyTask, required this.studyTask,
required this.isActive, required this.isActive,
required this.type, required this.type});
});
factory ClassGroup.fromJson(Map<String, dynamic> json) { factory ClassGroup.fromJson(Map<String, dynamic> json) {
return ClassGroup( return ClassGroup(
uid: json['Uid'], uid: json['Uid'],
name: json['Nev'], name: json['Nev'],
headTeacher: json['OsztalyFonok'] != null headTeacher: json['OsztalyFonok'] != null
? UidObj.fromJson(json['OsztalyFonok']) ? UidObj.fromJson(json['OsztalyFonok'])
: null, : null,
substituteHeadTeacher: json['OsztalyFonokHelyettes'] != null substituteHeadTeacher: json['OsztalyFonokHelyettes'] != null
? UidObj.fromJson(json['OsztalyFonokHelyettes']) ? UidObj.fromJson(json['OsztalyFonokHelyettes'])
: null, : null,
studyGroup: NameUidDesc.fromJson(json['OktatasNevelesiKategoria']), studyGroup: NameUidDesc.fromJson(json['OktatasNevelesiKategoria']),
studyGroupSortIndex: json['OktatasNevelesiKategoriaSortIndex'], studyGroupSortIndex: json['OktatasNevelesiKategoriaSortIndex'],
studyTask: json['OktatasNevelesiFeladat'] != null studyTask: json['OktatasNevelesiFeladat'] != null
? NameUidDesc.fromJson(json['OktatasNevelesiFeladat']) ? NameUidDesc.fromJson(json['OktatasNevelesiFeladat'])
: null, : null,
isActive: json['IsAktiv'], isActive: json['IsAktiv'],
type: json['Tipus'], type: json['Tipus']);
);
} }
@override @override

View File

@@ -3,18 +3,12 @@ class NameUidDesc {
final String? name; final String? name;
final String? description; final String? description;
NameUidDesc({ NameUidDesc(
required this.uid, {required this.uid, required this.name, required this.description});
required this.name,
required this.description,
});
factory NameUidDesc.fromJson(Map<String, dynamic> json) { factory NameUidDesc.fromJson(Map<String, dynamic> json) {
return NameUidDesc( return NameUidDesc(
uid: json['Uid'], uid: json['Uid'], name: json['Nev'], description: json['Leiras']);
name: json['Nev'],
description: json['Leiras'],
);
} }
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@@ -35,14 +29,23 @@ class NameUid {
final String uid; final String uid;
final String name; final String name;
NameUid({required this.uid, required this.name}); NameUid({
required this.uid,
required this.name,
});
factory NameUid.fromJson(Map<String, dynamic> json) { factory NameUid.fromJson(Map<String, dynamic> json) {
return NameUid(uid: json['Uid'], name: json['Nev']); return NameUid(
uid: json['Uid'],
name: json['Nev'],
);
} }
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return {'Uid': uid, 'Nev': name}; return {
'Uid': uid,
'Nev': name,
};
} }
} }
@@ -52,7 +55,9 @@ class UidObj {
UidObj({required this.uid}); UidObj({required this.uid});
factory UidObj.fromJson(Map<String, dynamic> json) { factory UidObj.fromJson(Map<String, dynamic> json) {
return UidObj(uid: json['Uid']); return UidObj(
uid: json['Uid'],
);
} }
@override @override

View File

@@ -1,5 +1,5 @@
import 'generic.dart'; import 'package:firka/helpers/api/model/generic.dart';
import 'subject.dart'; import 'package:firka/helpers/api/model/subject.dart';
class Grade { class Grade {
final String uid; final String uid;
@@ -20,25 +20,24 @@ class Grade {
final UidObj? classGroup; final UidObj? classGroup;
final int sortIndex; final int sortIndex;
Grade({ Grade(
required this.uid, {required this.uid,
required this.recordDate, required this.recordDate,
required this.creationDate, required this.creationDate,
this.ackDate, this.ackDate,
required this.subject, required this.subject,
this.topic, this.topic,
required this.type, required this.type,
this.mode, this.mode,
required this.valueType, required this.valueType,
required this.teacher, required this.teacher,
this.kind, this.kind,
this.numericValue, this.numericValue,
required this.strValue, required this.strValue,
this.weightPercentage, this.weightPercentage,
this.shortStrValue, this.shortStrValue,
this.classGroup, this.classGroup,
required this.sortIndex, required this.sortIndex});
});
factory Grade.fromJson(Map<String, dynamic> json) { factory Grade.fromJson(Map<String, dynamic> json) {
return Grade( return Grade(
@@ -66,28 +65,6 @@ class Grade {
); );
} }
Map<String, dynamic> toJson() {
return {
'Uid': uid,
'RogzitesDatuma': recordDate.toUtc().toIso8601String(),
'KeszitesDatuma': creationDate.toUtc().toIso8601String(),
'LattamozasDatuma': ackDate?.toUtc().toIso8601String(),
'Tantargy': subject.toJson(),
'Tema': topic,
'Tipus': type.toJson(),
'Mod': mode?.toJson(),
'ErtekFajta': valueType.toJson(),
'ErtekeloTanarNeve': teacher,
'Kind': kind,
'SzamErtek': numericValue,
'SzovegesErtek': strValue,
'SulySzazalekErteke': weightPercentage,
'SzovegesErtekelesRovidNev': shortStrValue,
'OsztalyCsoport': classGroup != null ? {'Uid': classGroup!.uid} : null,
'SortIndex': sortIndex,
};
}
@override @override
String toString() { String toString() {
return 'Grade(' return 'Grade('

View File

@@ -5,22 +5,20 @@ class Guardian {
final String? phoneNumber; final String? phoneNumber;
final String uid; final String uid;
Guardian({ Guardian(
required this.email, {required this.email,
required this.isLegalRepresentative, required this.isLegalRepresentative,
required this.name, required this.name,
required this.phoneNumber, required this.phoneNumber,
required this.uid, required this.uid});
});
factory Guardian.fromJson(Map<String, dynamic> json) { factory Guardian.fromJson(Map<String, dynamic> json) {
return Guardian( return Guardian(
email: json['EmailCim'], email: json['EmailCim'],
isLegalRepresentative: json['IsTorvenyesKepviselo'], isLegalRepresentative: json['IsTorvenyesKepviselo'],
name: json['Nev'], name: json['Nev'],
phoneNumber: json['Telefonszam'], phoneNumber: json['Telefonszam'],
uid: json['Uid'], uid: json['Uid']);
);
} }
@override @override

View File

@@ -0,0 +1,70 @@
import 'package:firka/helpers/api/model/subject.dart';
import 'generic.dart';
class Homework {
final String uid;
final Subject subject;
final String subjectName;
final String teacherName;
final String description;
final DateTime startDate;
final DateTime dueDate;
final DateTime creationDate;
final bool isCreatedByTeacher;
final bool isDone;
final bool canBeSubmitted;
final UidObj classGroup;
final bool canAttach;
Homework(
{required this.uid,
required this.subject,
required this.subjectName,
required this.teacherName,
required this.description,
required this.startDate,
required this.dueDate,
required this.creationDate,
required this.isCreatedByTeacher,
required this.isDone,
required this.canBeSubmitted,
required this.classGroup,
required this.canAttach});
factory Homework.fromJson(Map<String, dynamic> json) {
return Homework(
uid: json["Uid"],
subject: Subject.fromJson(json["Tantargy"]),
subjectName: json["TantargyNeve"],
teacherName: json["RogzitoTanarNeve"],
description: json["Szoveg"],
startDate: DateTime.parse(json["FeladasDatuma"]).toLocal(),
dueDate: DateTime.parse(json["HataridoDatuma"]).toLocal(),
creationDate: DateTime.parse(json["RogzitesIdopontja"]).toLocal(),
isCreatedByTeacher: json["IsTanarRogzitette"],
isDone: json["IsMegoldva"],
canBeSubmitted: json["IsBeadhato"],
classGroup: UidObj.fromJson(json["OsztalyCsoport"]),
canAttach: json["IsCsatolasEngedelyezes"]);
}
@override
String toString() {
return 'Homework('
'uid: "$uid", '
'subject: $subject, '
'subjectName: "$subjectName", '
'teacherName: "$teacherName", '
'description: "$description", '
'startDate: $startDate, '
'dueDate: $dueDate, '
'creationDate: $creationDate, '
'isCreatedByTeacher: $isCreatedByTeacher, '
'isDone: $isDone, '
'canBeSubmitted: $canBeSubmitted, '
'classGroup: $classGroup, '
'canAttach: $canAttach'
')';
}
}

View File

@@ -4,12 +4,11 @@ class Institution {
final List<SystemModule> systemModuleList; final List<SystemModule> systemModuleList;
final String uid; final String uid;
Institution({ Institution(
required this.customizationSettings, {required this.customizationSettings,
required this.shortName, required this.shortName,
required this.systemModuleList, required this.systemModuleList,
required this.uid, required this.uid});
});
factory Institution.fromJson(Map<String, dynamic> json) { factory Institution.fromJson(Map<String, dynamic> json) {
var systemModuleList = List<SystemModule>.empty(growable: true); var systemModuleList = List<SystemModule>.empty(growable: true);
@@ -19,9 +18,8 @@ class Institution {
} }
return Institution( return Institution(
customizationSettings: CustomizationSettings.fromJson( customizationSettings:
json['TestreszabasBeallitasok'], CustomizationSettings.fromJson(json['TestreszabasBeallitasok']),
),
shortName: json['RovidNev'], shortName: json['RovidNev'],
systemModuleList: systemModuleList, systemModuleList: systemModuleList,
uid: json['Uid'], uid: json['Uid'],
@@ -35,21 +33,19 @@ class CustomizationSettings {
final bool isLessonsThemeVisible; final bool isLessonsThemeVisible;
final String nextServerDeployAsString; final String nextServerDeployAsString;
CustomizationSettings({ CustomizationSettings(
required this.delayForNotifications, {required this.delayForNotifications,
required this.isClassAverageVisible, required this.isClassAverageVisible,
required this.isLessonsThemeVisible, required this.isLessonsThemeVisible,
required this.nextServerDeployAsString, required this.nextServerDeployAsString});
});
factory CustomizationSettings.fromJson(Map<String, dynamic> json) { factory CustomizationSettings.fromJson(Map<String, dynamic> json) {
return CustomizationSettings( return CustomizationSettings(
delayForNotifications: delayForNotifications:
json['ErtekelesekMegjelenitesenekKesleltetesenekMerteke'], json['ErtekelesekMegjelenitesenekKesleltetesenekMerteke'],
isClassAverageVisible: json['IsOsztalyAtlagMegjeleniteseEllenorzoben'], isClassAverageVisible: json['IsOsztalyAtlagMegjeleniteseEllenorzoben'],
isLessonsThemeVisible: json['IsTanorakTemajaMegtekinthetoEllenorzoben'], isLessonsThemeVisible: json['IsTanorakTemajaMegtekinthetoEllenorzoben'],
nextServerDeployAsString: json['KovetkezoTelepitesDatuma'], nextServerDeployAsString: json['KovetkezoTelepitesDatuma']);
);
} }
@override @override
@@ -72,10 +68,7 @@ class SystemModule {
factory SystemModule.fromJson(Map<String, dynamic> json) { factory SystemModule.fromJson(Map<String, dynamic> json) {
return SystemModule( return SystemModule(
isActive: json['IsAktiv'], isActive: json['IsAktiv'], type: json['Tipus'], url: json['Url']);
type: json['Tipus'],
url: json['Url'],
);
} }
@override @override

View File

@@ -1,4 +1,4 @@
import 'generic.dart'; import 'package:firka/helpers/api/model/generic.dart';
class NoticeBoardItem { class NoticeBoardItem {
final String uid; final String uid;
@@ -9,26 +9,24 @@ class NoticeBoardItem {
final String contentHTML; final String contentHTML;
final String contentText; final String contentText;
NoticeBoardItem({ NoticeBoardItem(
required this.uid, {required this.uid,
required this.author, required this.author,
required this.validFrom, required this.validFrom,
required this.validTo, required this.validTo,
required this.title, required this.title,
required this.contentHTML, required this.contentHTML,
required this.contentText, required this.contentText});
});
factory NoticeBoardItem.fromJson(Map<String, dynamic> json) { factory NoticeBoardItem.fromJson(Map<String, dynamic> json) {
return NoticeBoardItem( return NoticeBoardItem(
uid: json['Uid'], uid: json['Uid'],
author: json['RogzitoNeve'], author: json['RogzitoNeve'],
validFrom: DateTime.parse(json['ErvenyessegKezdete']), validFrom: DateTime.parse(json['ErvenyessegKezdete']),
validTo: DateTime.parse(json['ErvenyessegVege']), validTo: DateTime.parse(json['ErvenyessegVege']),
title: json['Cim'], title: json['Cim'],
contentHTML: json['Tartalom'], contentHTML: json['Tartalom'],
contentText: json['TartalomText'], contentText: json['TartalomText']);
);
} }
@override @override
@@ -55,28 +53,26 @@ class InfoBoardItem {
final String contentText; final String contentText;
final NameUidDesc type; final NameUidDesc type;
InfoBoardItem({ InfoBoardItem(
required this.uid, {required this.uid,
required this.title, required this.title,
required this.date, required this.date,
required this.author, required this.author,
required this.createdAt, required this.createdAt,
required this.contentHTML, required this.contentHTML,
required this.contentText, required this.contentText,
required this.type, required this.type});
});
factory InfoBoardItem.fromJson(Map<String, dynamic> json) { factory InfoBoardItem.fromJson(Map<String, dynamic> json) {
return InfoBoardItem( return InfoBoardItem(
uid: json['Uid'], uid: json['Uid'],
title: json['Cim'], title: json['Cim'],
date: DateTime.parse(json['Datum']), date: DateTime.parse(json['Datum']),
author: json['KeszitoTanarNeve'], author: json['KeszitoTanarNeve'],
createdAt: DateTime.parse(json['KeszitesDatuma']), createdAt: DateTime.parse(json['KeszitesDatuma']),
contentText: json['Tartalom'], contentText: json['Tartalom'],
contentHTML: json['TartalomFormazott'], contentHTML: json['TartalomFormazott'],
type: NameUidDesc.fromJson(json['Tipus']), type: NameUidDesc.fromJson(json['Tipus']));
);
} }
@override @override

View File

@@ -1,5 +1,5 @@
import 'generic.dart'; import 'package:firka/helpers/api/model/generic.dart';
import 'subject.dart'; import 'package:firka/helpers/api/model/subject.dart';
class Omission { class Omission {
final String uid; final String uid;
@@ -75,7 +75,11 @@ class Class {
final DateTime end; final DateTime end;
final int classNo; final int classNo;
Class({required this.start, required this.end, required this.classNo}); Class({
required this.start,
required this.end,
required this.classNo,
});
factory Class.fromJson(Map<String, dynamic> json) { factory Class.fromJson(Map<String, dynamic> json) {
return Class( return Class(

View File

@@ -0,0 +1,115 @@
import 'package:firka/helpers/api/model/guardian.dart';
import 'package:firka/helpers/api/model/institution.dart';
import 'package:firka/helpers/json_helper.dart';
import 'package:intl/intl.dart';
class Student {
final List<String> addressDataList;
final BankAccount bankAccount;
// final int yearOfBirth;
// final int monthOfBirth;
// final int dayOfBirth;
final DateTime birthdate;
final String? emailAddress;
final String name;
final String? phoneNumber;
final String schoolYearUID;
final String uid;
final List<Guardian> guardianList;
final String instituteCode;
final String instituteName;
final Institution institution;
Student(
{required this.addressDataList,
required this.bankAccount,
// required this.yearOfBirth,
// required this.monthOfBirth,
// required this.dayOfBirth,
required this.birthdate,
required this.emailAddress,
required this.name,
required this.phoneNumber,
required this.schoolYearUID,
required this.uid,
required this.guardianList,
required this.instituteCode,
required this.instituteName,
required this.institution});
factory Student.fromJson(Map<String, dynamic> json) {
var guardianList = List<Guardian>.empty(growable: true);
for (var item in json['Gondviselok']) {
guardianList.add(Guardian.fromJson(item));
}
return Student(
addressDataList: listToTyped<String>(json['Cimek']),
bankAccount: BankAccount.fromJson(json['Bankszamla']),
birthdate: DateFormat('yyyy-M-d').parse(
"${json['SzuletesiEv']}-${json['SzuletesiHonap']}-${json['SzuletesiNap']}"),
emailAddress: json['EmailCim'],
name: json['Nev'],
phoneNumber: json['Telefonszam'],
schoolYearUID: json['TanevUid'],
uid: json['Uid'],
guardianList: guardianList,
instituteCode: json['IntezmenyAzonosito'],
instituteName: json['IntezmenyNev'],
institution: Institution.fromJson(json['Intezmeny']));
}
@override
String toString() {
return 'Student('
'addressDataList: [$addressDataList], '
'bankAccount: $bankAccount, '
'birthDate: $birthdate, '
'emailAddress: "$emailAddress", '
'name: "$name", '
'phoneNumber: "$phoneNumber", '
'schoolYearUID: "$schoolYearUID", '
'uid: "$uid", '
'guardianList: [$guardianList], '
'instituteCode: "$instituteCode", '
'instituteName: "$instituteName", '
')';
}
}
class BankAccount {
final String? accountNumber;
final bool? isReadOnly;
final String? ownerName;
final int? ownerType;
BankAccount(
{required this.accountNumber,
required this.isReadOnly,
required this.ownerName,
required this.ownerType});
factory BankAccount.fromJson(Map<String, dynamic> json) {
return BankAccount(
accountNumber: json['BankszamlaSzam'],
isReadOnly: json['IsReadOnly'],
ownerName: json['BankszamlaTulajdonosNeve'],
ownerType: json['BankszamlaTulajdonosTipusId']);
}
@override
String toString() {
return 'BankAccount('
'accountNumber: "$accountNumber", '
'isReadOnly: "$isReadOnly", '
'ownerName: "$ownerName", '
'ownerType: "$ownerType"'
')';
}
}

View File

@@ -1,5 +1,6 @@
import 'package:firka/helpers/api/model/subject.dart';
import 'generic.dart'; import 'generic.dart';
import 'subject.dart';
class Test { class Test {
final String uid; final String uid;

View File

@@ -1,5 +1,5 @@
import 'generic.dart'; import 'package:firka/helpers/api/model/generic.dart';
import 'subject.dart'; import 'package:firka/helpers/api/model/subject.dart';
class Lesson { class Lesson {
final String uid; final String uid;
@@ -83,9 +83,8 @@ class Lesson {
? NameUid.fromJson(json['OsztalyCsoport']) ? NameUid.fromJson(json['OsztalyCsoport'])
: null, : null,
teacher: json['TanarNeve'], teacher: json['TanarNeve'],
subject: json['Tantargy'] != null subject:
? Subject.fromJson(json['Tantargy']) json['Tantargy'] != null ? Subject.fromJson(json['Tantargy']) : null,
: null,
theme: json['Tema'], theme: json['Tema'],
roomName: json['TeremNeve'], roomName: json['TeremNeve'],
type: NameUidDesc.fromJson(json['Tipus']), type: NameUidDesc.fromJson(json['Tipus']),
@@ -106,8 +105,8 @@ class Lesson {
digitalPlatformType: json['DigitalisPlatformTipus'], digitalPlatformType: json['DigitalisPlatformTipus'],
digitalSupportDeviceTypeList: digitalSupportDeviceTypeList:
json['DigitalisTamogatoEszkozTipusList'] != null json['DigitalisTamogatoEszkozTipusList'] != null
? List<String>.from(json['DigitalisTamogatoEszkozTipusList']) ? List<String>.from(json['DigitalisTamogatoEszkozTipusList'])
: List<String>.empty(), : List<String>.empty(),
createdAt: DateTime.parse(json['Letrehozas']).toLocal(), createdAt: DateTime.parse(json['Letrehozas']).toLocal(),
lastModifiedAt: DateTime.parse(json['UtolsoModositas']).toLocal(), lastModifiedAt: DateTime.parse(json['UtolsoModositas']).toLocal(),
); );
@@ -128,7 +127,7 @@ class Lesson {
'Nev': name, 'Nev': name,
'Oraszam': lessonNumber, 'Oraszam': lessonNumber,
'OraEvesSorszama': lessonSeqNumber, 'OraEvesSorszama': lessonSeqNumber,
'OsztalyCsoport': classGroup?.toJson(), 'OsztalyCsoport': classGroup,
'TanarNeve': teacher, 'TanarNeve': teacher,
'Tantargy': subject?.toJson(), 'Tantargy': subject?.toJson(),
'Tema': theme, 'Tema': theme,

View File

@@ -6,24 +6,22 @@ class TokenGrantResponse {
final String refreshToken; final String refreshToken;
final String scope; final String scope;
TokenGrantResponse({ TokenGrantResponse(
required this.idToken, {required this.idToken,
required this.accessToken, required this.accessToken,
required this.expiresIn, required this.expiresIn,
required this.tokenType, required this.tokenType,
required this.refreshToken, required this.refreshToken,
required this.scope, required this.scope});
});
factory TokenGrantResponse.fromJson(Map<String, dynamic> json) { factory TokenGrantResponse.fromJson(Map<String, dynamic> json) {
return TokenGrantResponse( return TokenGrantResponse(
idToken: json['id_token'], idToken: json['id_token'],
accessToken: json['access_token'], accessToken: json['access_token'],
expiresIn: json['expires_in'], expiresIn: json['expires_in'],
tokenType: json['token_type'], tokenType: json['token_type'],
refreshToken: json['refresh_token'], refreshToken: json['refresh_token'],
scope: json['scope'], scope: json['scope']);
);
} }
@override @override

View File

@@ -1,8 +1,9 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:firka/data/models/token_model.dart'; import 'package:firka/helpers/api/exceptions/token.dart';
import 'package:kreta_api/kreta_api.dart' hide KretaEndpoints; import 'package:firka/helpers/api/resp/token_grant.dart';
import 'package:firka/helpers/db/models/token_model.dart';
import 'package:firka/app/app_state.dart'; import '../../main.dart';
import 'consts.dart'; import 'consts.dart';
Future<TokenGrantResponse> getAccessToken(String code) async { Future<TokenGrantResponse> getAccessToken(String code) async {
@@ -22,11 +23,8 @@ Future<TokenGrantResponse> getAccessToken(String code) async {
}; };
try { try {
final response = await dio.post( final response = await dio.post(KretaEndpoints.tokenGrantUrl,
KretaEndpoints.tokenGrantUrl, options: Options(headers: headers), data: formData);
options: Options(headers: headers),
data: formData,
);
switch (response.statusCode) { switch (response.statusCode) {
case 200: case 200:
@@ -35,8 +33,7 @@ Future<TokenGrantResponse> getAccessToken(String code) async {
throw Exception("Invalid grant"); throw Exception("Invalid grant");
default: default:
throw Exception( throw Exception(
"Failed to get access token, response code: ${response.statusCode}", "Failed to get access token, response code: ${response.statusCode}");
);
} }
} catch (e) { } catch (e) {
rethrow; rethrow;
@@ -47,8 +44,7 @@ const _tokenRefreshRetryDelays = [1000, 3000, 5000];
Future<TokenGrantResponse> extendToken(TokenModel model) async { Future<TokenGrantResponse> extendToken(TokenModel model) async {
logger.info( logger.info(
"Extending token for user: ${model.studentId}, institute: ${model.iss}", "Extending token for user: ${model.studentId}, institute: ${model.iss}");
);
final headers = <String, String>{ final headers = <String, String>{
"content-type": "application/x-www-form-urlencoded; charset=UTF-8", "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
@@ -70,38 +66,30 @@ Future<TokenGrantResponse> extendToken(TokenModel model) async {
if (attempt > 0) { if (attempt > 0) {
final delay = _tokenRefreshRetryDelays[attempt - 1]; final delay = _tokenRefreshRetryDelays[attempt - 1];
logger.info( logger.info(
"Token refresh attempt ${attempt + 1}, waiting ${delay}ms...", "Token refresh attempt ${attempt + 1}, waiting ${delay}ms...");
);
await Future.delayed(Duration(milliseconds: delay)); await Future.delayed(Duration(milliseconds: delay));
} }
final response = await dio.post( final response = await dio.post(KretaEndpoints.tokenGrantUrl,
KretaEndpoints.tokenGrantUrl, options: Options(headers: headers), data: formData);
options: Options(headers: headers),
data: formData,
);
switch (response.statusCode) { switch (response.statusCode) {
case 200: case 200:
logger.info( logger
"Token extended successfully for user: ${model.studentId}", .info("Token extended successfully for user: ${model.studentId}");
);
return TokenGrantResponse.fromJson(response.data); return TokenGrantResponse.fromJson(response.data);
case 400: case 400:
case 401: case 401:
logger.warning( logger.warning(
"Token refresh failed (${response.statusCode}) - refresh token invalid for user: ${model.studentId}", "Token refresh failed (${response.statusCode}) - refresh token invalid for user: ${model.studentId}");
);
throw response.statusCode == 400 throw response.statusCode == 400
? TokenExpiredException() ? TokenExpiredException()
: InvalidGrantException(); : InvalidGrantException();
default: default:
logger.warning( logger.warning(
"Token refresh failed (${response.statusCode}) for user: ${model.studentId}, attempt ${attempt + 1}", "Token refresh failed (${response.statusCode}) for user: ${model.studentId}, attempt ${attempt + 1}");
);
lastError = Exception( lastError = Exception(
"Failed to get access token, response code: ${response.statusCode}", "Failed to get access token, response code: ${response.statusCode}");
);
// Continue to retry for network errors // Continue to retry for network errors
continue; continue;
} }
@@ -111,8 +99,7 @@ Future<TokenGrantResponse> extendToken(TokenModel model) async {
rethrow; rethrow;
} on DioException catch (e) { } on DioException catch (e) {
logger.warning( logger.warning(
"Token refresh network error for user: ${model.studentId}, attempt ${attempt + 1}: $e", "Token refresh network error for user: ${model.studentId}, attempt ${attempt + 1}: $e");
);
lastError = e; lastError = e;
continue; continue;
} catch (e) { } catch (e) {
@@ -122,8 +109,7 @@ Future<TokenGrantResponse> extendToken(TokenModel model) async {
} }
} }
logger.severe( logger
"All token refresh attempts failed for user: ${model.studentId}", .severe("All token refresh attempts failed for user: ${model.studentId}");
);
throw lastError ?? Exception("Token refresh failed after all retries"); throw lastError ?? Exception("Token refresh failed after all retries");
} }

View File

@@ -1,7 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:kreta_api/kreta_api.dart'; import 'package:firka/helpers/api/model/grade.dart';
import 'package:firka/helpers/api/model/timetable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -13,9 +14,7 @@ class IOSWidgetHelper {
if (!Platform.isIOS) return null; if (!Platform.isIOS) return null;
try { try {
final result = await _channel.invokeMethod<String>( final result = await _channel.invokeMethod<String>('getAppGroupDirectory');
'getAppGroupDirectory',
);
if (result != null) { if (result != null) {
return Directory(result); return Directory(result);
} }
@@ -40,12 +39,8 @@ class IOSWidgetHelper {
if (!Platform.isIOS) return; if (!Platform.isIOS) return;
debugPrint('[IOSWidget] Starting updateWidgetData...'); debugPrint('[IOSWidget] Starting updateWidgetData...');
debugPrint( debugPrint('[IOSWidget] todayLessons: ${todayLessons.length}, tomorrowLessons: ${tomorrowLessons.length}');
'[IOSWidget] todayLessons: ${todayLessons.length}, tomorrowLessons: ${tomorrowLessons.length}', debugPrint('[IOSWidget] grades: ${grades.length}, subjectAverages: ${subjectAverages.length}');
);
debugPrint(
'[IOSWidget] grades: ${grades.length}, subjectAverages: ${subjectAverages.length}',
);
final dir = await _getAppGroupDirectory(); final dir = await _getAppGroupDirectory();
if (dir == null) { if (dir == null) {
@@ -61,31 +56,23 @@ class IOSWidgetHelper {
'timetable': { 'timetable': {
'today': todayLessons.map((l) => _lessonToJson(l)).toList(), 'today': todayLessons.map((l) => _lessonToJson(l)).toList(),
'tomorrow': tomorrowLessons.map((l) => _lessonToJson(l)).toList(), 'tomorrow': tomorrowLessons.map((l) => _lessonToJson(l)).toList(),
'nextSchoolDay': nextSchoolDayLessons 'nextSchoolDay': nextSchoolDayLessons.map((l) => _lessonToJson(l)).toList(),
.map((l) => _lessonToJson(l))
.toList(),
'nextSchoolDayDate': nextSchoolDayDate?.toIso8601String(), 'nextSchoolDayDate': nextSchoolDayDate?.toIso8601String(),
'currentBreak': currentBreak != null 'currentBreak': currentBreak != null ? {
? { 'name': currentBreak.name,
'name': currentBreak.name, 'nameKey': currentBreak.nameKey,
'nameKey': currentBreak.nameKey, 'endDate': currentBreak.endDate.toIso8601String(),
'endDate': currentBreak.endDate.toIso8601String(), } : null,
}
: null,
}, },
'grades': grades.take(20).map((g) => _gradeToJson(g)).toList(), 'grades': grades.take(20).map((g) => _gradeToJson(g)).toList(),
'averages': { 'averages': {
'overall': overallAverage, 'overall': overallAverage,
'subjects': subjectAverages.entries 'subjects': subjectAverages.entries.map((e) => {
.map( 'uid': e.key,
(e) => { 'name': _getSubjectNameFromGrades(e.key, grades),
'uid': e.key, 'average': e.value,
'name': _getSubjectNameFromGrades(e.key, grades), 'gradeCount': _getGradeCount(e.key, grades),
'average': e.value, }).toList(),
'gradeCount': _getGradeCount(e.key, grades),
},
)
.toList(),
}, },
}; };
@@ -131,29 +118,26 @@ class IOSWidgetHelper {
'name': lesson.name, 'name': lesson.name,
'lessonNumber': lesson.lessonNumber, 'lessonNumber': lesson.lessonNumber,
'teacher': lesson.teacher, 'teacher': lesson.teacher,
'subject': subject != null 'subject': subject != null ? {
? { 'uid': subject.uid,
'uid': subject.uid, 'name': subject.name,
'name': subject.name, 'category': subject.category != null ? {
'category': { 'uid': subject.category!.uid,
'uid': subject.category.uid, 'name': subject.category!.name,
'name': subject.category.name, 'description': subject.category!.description,
'description': subject.category.description, } : null,
}, 'sortIndex': subject.sortIndex,
'sortIndex': subject.sortIndex, 'teacherName': subject.teacherName,
'teacherName': subject.teacherName, } : {
} 'uid': '',
: { 'name': lesson.name,
'uid': '', 'category': null,
'name': lesson.name, 'sortIndex': 0,
'category': null, 'teacherName': null,
'sortIndex': 0, },
'teacherName': null,
},
'theme': lesson.theme, 'theme': lesson.theme,
'roomName': lesson.roomName, 'roomName': lesson.roomName,
'isCancelled': 'isCancelled': lesson.state.name?.toLowerCase().contains('elmarad') ?? false,
lesson.state.name?.toLowerCase().contains('elmarad') ?? false,
'isSubstitution': lesson.substituteTeacher != null, 'isSubstitution': lesson.substituteTeacher != null,
}; };
} }
@@ -165,11 +149,11 @@ class IOSWidgetHelper {
'subject': { 'subject': {
'uid': grade.subject.uid, 'uid': grade.subject.uid,
'name': grade.subject.name, 'name': grade.subject.name,
'category': { 'category': grade.subject.category != null ? {
'uid': grade.subject.category.uid, 'uid': grade.subject.category!.uid,
'name': grade.subject.category.name, 'name': grade.subject.category!.name,
'description': grade.subject.category.description, 'description': grade.subject.category!.description,
}, } : null,
'sortIndex': grade.subject.sortIndex, 'sortIndex': grade.subject.sortIndex,
// Use the grade's teacher field, not subject.teacherName (which is usually null for grades) // Use the grade's teacher field, not subject.teacherName (which is usually null for grades)
'teacherName': grade.teacher, 'teacherName': grade.teacher,

View File

@@ -1,4 +1,4 @@
import 'package:isar_community/isar.dart'; import 'package:isar/isar.dart';
part 'app_settings_model.g.dart'; part 'app_settings_model.g.dart';

View File

@@ -0,0 +1,827 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_settings_model.dart';
// **************************************************************************
// IsarCollectionGenerator
// **************************************************************************
// coverage:ignore-file
// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types
extension GetAppSettingsModelCollection on Isar {
IsarCollection<AppSettingsModel> get appSettingsModels => this.collection();
}
const AppSettingsModelSchema = CollectionSchema(
name: r'AppSettingsModel',
id: -638838212012723081,
properties: {
r'valueBool': PropertySchema(
id: 0,
name: r'valueBool',
type: IsarType.bool,
),
r'valueDouble': PropertySchema(
id: 1,
name: r'valueDouble',
type: IsarType.double,
),
r'valueIndex': PropertySchema(
id: 2,
name: r'valueIndex',
type: IsarType.long,
),
r'valueString': PropertySchema(
id: 3,
name: r'valueString',
type: IsarType.string,
)
},
estimateSize: _appSettingsModelEstimateSize,
serialize: _appSettingsModelSerialize,
deserialize: _appSettingsModelDeserialize,
deserializeProp: _appSettingsModelDeserializeProp,
idName: r'id',
indexes: {},
links: {},
embeddedSchemas: {},
getId: _appSettingsModelGetId,
getLinks: _appSettingsModelGetLinks,
attach: _appSettingsModelAttach,
version: '3.1.0+1',
);
int _appSettingsModelEstimateSize(
AppSettingsModel object,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
var bytesCount = offsets.last;
{
final value = object.valueString;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
}
return bytesCount;
}
void _appSettingsModelSerialize(
AppSettingsModel object,
IsarWriter writer,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
writer.writeBool(offsets[0], object.valueBool);
writer.writeDouble(offsets[1], object.valueDouble);
writer.writeLong(offsets[2], object.valueIndex);
writer.writeString(offsets[3], object.valueString);
}
AppSettingsModel _appSettingsModelDeserialize(
Id id,
IsarReader reader,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
final object = AppSettingsModel();
object.id = id;
object.valueBool = reader.readBoolOrNull(offsets[0]);
object.valueDouble = reader.readDoubleOrNull(offsets[1]);
object.valueIndex = reader.readLongOrNull(offsets[2]);
object.valueString = reader.readStringOrNull(offsets[3]);
return object;
}
P _appSettingsModelDeserializeProp<P>(
IsarReader reader,
int propertyId,
int offset,
Map<Type, List<int>> allOffsets,
) {
switch (propertyId) {
case 0:
return (reader.readBoolOrNull(offset)) as P;
case 1:
return (reader.readDoubleOrNull(offset)) as P;
case 2:
return (reader.readLongOrNull(offset)) as P;
case 3:
return (reader.readStringOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
}
}
Id _appSettingsModelGetId(AppSettingsModel object) {
return object.id ?? Isar.autoIncrement;
}
List<IsarLinkBase<dynamic>> _appSettingsModelGetLinks(AppSettingsModel object) {
return [];
}
void _appSettingsModelAttach(
IsarCollection<dynamic> col, Id id, AppSettingsModel object) {
object.id = id;
}
extension AppSettingsModelQueryWhereSort
on QueryBuilder<AppSettingsModel, AppSettingsModel, QWhere> {
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterWhere> anyId() {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(const IdWhereClause.any());
});
}
}
extension AppSettingsModelQueryWhere
on QueryBuilder<AppSettingsModel, AppSettingsModel, QWhereClause> {
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterWhereClause> idEqualTo(
Id id) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: id,
upper: id,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterWhereClause>
idNotEqualTo(Id id) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(
IdWhereClause.lessThan(upper: id, includeUpper: false),
)
.addWhereClause(
IdWhereClause.greaterThan(lower: id, includeLower: false),
);
} else {
return query
.addWhereClause(
IdWhereClause.greaterThan(lower: id, includeLower: false),
)
.addWhereClause(
IdWhereClause.lessThan(upper: id, includeUpper: false),
);
}
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterWhereClause>
idGreaterThan(Id id, {bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.greaterThan(lower: id, includeLower: include),
);
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterWhereClause>
idLessThan(Id id, {bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.lessThan(upper: id, includeUpper: include),
);
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterWhereClause> idBetween(
Id lowerId,
Id upperId, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: lowerId,
includeLower: includeLower,
upper: upperId,
includeUpper: includeUpper,
));
});
}
}
extension AppSettingsModelQueryFilter
on QueryBuilder<AppSettingsModel, AppSettingsModel, QFilterCondition> {
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
idIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'id',
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
idIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'id',
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
idEqualTo(Id? value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'id',
value: value,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
idGreaterThan(
Id? value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'id',
value: value,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
idLessThan(
Id? value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'id',
value: value,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
idBetween(
Id? lower,
Id? upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'id',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueBoolIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'valueBool',
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueBoolIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'valueBool',
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueBoolEqualTo(bool? value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'valueBool',
value: value,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueDoubleIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'valueDouble',
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueDoubleIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'valueDouble',
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueDoubleEqualTo(
double? value, {
double epsilon = Query.epsilon,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'valueDouble',
value: value,
epsilon: epsilon,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueDoubleGreaterThan(
double? value, {
bool include = false,
double epsilon = Query.epsilon,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'valueDouble',
value: value,
epsilon: epsilon,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueDoubleLessThan(
double? value, {
bool include = false,
double epsilon = Query.epsilon,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'valueDouble',
value: value,
epsilon: epsilon,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueDoubleBetween(
double? lower,
double? upper, {
bool includeLower = true,
bool includeUpper = true,
double epsilon = Query.epsilon,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'valueDouble',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
epsilon: epsilon,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueIndexIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'valueIndex',
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueIndexIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'valueIndex',
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueIndexEqualTo(int? value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'valueIndex',
value: value,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueIndexGreaterThan(
int? value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'valueIndex',
value: value,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueIndexLessThan(
int? value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'valueIndex',
value: value,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueIndexBetween(
int? lower,
int? upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'valueIndex',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueStringIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'valueString',
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueStringIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'valueString',
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueStringEqualTo(
String? value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'valueString',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueStringGreaterThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'valueString',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueStringLessThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'valueString',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueStringBetween(
String? lower,
String? upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'valueString',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueStringStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'valueString',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueStringEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'valueString',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueStringContains(String value, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'valueString',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueStringMatches(String pattern, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'valueString',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueStringIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'valueString',
value: '',
));
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterFilterCondition>
valueStringIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'valueString',
value: '',
));
});
}
}
extension AppSettingsModelQueryObject
on QueryBuilder<AppSettingsModel, AppSettingsModel, QFilterCondition> {}
extension AppSettingsModelQueryLinks
on QueryBuilder<AppSettingsModel, AppSettingsModel, QFilterCondition> {}
extension AppSettingsModelQuerySortBy
on QueryBuilder<AppSettingsModel, AppSettingsModel, QSortBy> {
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterSortBy>
sortByValueBool() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'valueBool', Sort.asc);
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterSortBy>
sortByValueBoolDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'valueBool', Sort.desc);
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterSortBy>
sortByValueDouble() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'valueDouble', Sort.asc);
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterSortBy>
sortByValueDoubleDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'valueDouble', Sort.desc);
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterSortBy>
sortByValueIndex() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'valueIndex', Sort.asc);
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterSortBy>
sortByValueIndexDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'valueIndex', Sort.desc);
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterSortBy>
sortByValueString() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'valueString', Sort.asc);
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterSortBy>
sortByValueStringDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'valueString', Sort.desc);
});
}
}
extension AppSettingsModelQuerySortThenBy
on QueryBuilder<AppSettingsModel, AppSettingsModel, QSortThenBy> {
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterSortBy> thenById() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc);
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterSortBy>
thenByIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.desc);
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterSortBy>
thenByValueBool() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'valueBool', Sort.asc);
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterSortBy>
thenByValueBoolDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'valueBool', Sort.desc);
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterSortBy>
thenByValueDouble() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'valueDouble', Sort.asc);
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterSortBy>
thenByValueDoubleDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'valueDouble', Sort.desc);
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterSortBy>
thenByValueIndex() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'valueIndex', Sort.asc);
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterSortBy>
thenByValueIndexDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'valueIndex', Sort.desc);
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterSortBy>
thenByValueString() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'valueString', Sort.asc);
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QAfterSortBy>
thenByValueStringDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'valueString', Sort.desc);
});
}
}
extension AppSettingsModelQueryWhereDistinct
on QueryBuilder<AppSettingsModel, AppSettingsModel, QDistinct> {
QueryBuilder<AppSettingsModel, AppSettingsModel, QDistinct>
distinctByValueBool() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'valueBool');
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QDistinct>
distinctByValueDouble() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'valueDouble');
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QDistinct>
distinctByValueIndex() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'valueIndex');
});
}
QueryBuilder<AppSettingsModel, AppSettingsModel, QDistinct>
distinctByValueString({bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'valueString', caseSensitive: caseSensitive);
});
}
}
extension AppSettingsModelQueryProperty
on QueryBuilder<AppSettingsModel, AppSettingsModel, QQueryProperty> {
QueryBuilder<AppSettingsModel, int, QQueryOperations> idProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'id');
});
}
QueryBuilder<AppSettingsModel, bool?, QQueryOperations> valueBoolProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'valueBool');
});
}
QueryBuilder<AppSettingsModel, double?, QQueryOperations>
valueDoubleProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'valueDouble');
});
}
QueryBuilder<AppSettingsModel, int?, QQueryOperations> valueIndexProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'valueIndex');
});
}
QueryBuilder<AppSettingsModel, String?, QQueryOperations>
valueStringProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'valueString');
});
}
}

View File

@@ -1,4 +1,4 @@
import 'package:isar_community/isar.dart'; import 'package:isar/isar.dart';
part 'generic_cache_model.g.dart'; part 'generic_cache_model.g.dart';
@@ -12,7 +12,7 @@ enum CacheId {
getClassGroup, getClassGroup,
getSubjectAvg, getSubjectAvg,
getLessons, getLessons,
getHomework, getHomework
} }
@collection @collection

View File

@@ -0,0 +1,494 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'generic_cache_model.dart';
// **************************************************************************
// IsarCollectionGenerator
// **************************************************************************
// coverage:ignore-file
// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types
extension GetGenericCacheModelCollection on Isar {
IsarCollection<GenericCacheModel> get genericCacheModels => this.collection();
}
const GenericCacheModelSchema = CollectionSchema(
name: r'GenericCacheModel',
id: 3174486726793780620,
properties: {
r'cacheData': PropertySchema(
id: 0,
name: r'cacheData',
type: IsarType.string,
)
},
estimateSize: _genericCacheModelEstimateSize,
serialize: _genericCacheModelSerialize,
deserialize: _genericCacheModelDeserialize,
deserializeProp: _genericCacheModelDeserializeProp,
idName: r'cacheKey',
indexes: {},
links: {},
embeddedSchemas: {},
getId: _genericCacheModelGetId,
getLinks: _genericCacheModelGetLinks,
attach: _genericCacheModelAttach,
version: '3.1.0+1',
);
int _genericCacheModelEstimateSize(
GenericCacheModel object,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
var bytesCount = offsets.last;
{
final value = object.cacheData;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
}
return bytesCount;
}
void _genericCacheModelSerialize(
GenericCacheModel object,
IsarWriter writer,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
writer.writeString(offsets[0], object.cacheData);
}
GenericCacheModel _genericCacheModelDeserialize(
Id id,
IsarReader reader,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
final object = GenericCacheModel();
object.cacheData = reader.readStringOrNull(offsets[0]);
object.cacheKey = id;
return object;
}
P _genericCacheModelDeserializeProp<P>(
IsarReader reader,
int propertyId,
int offset,
Map<Type, List<int>> allOffsets,
) {
switch (propertyId) {
case 0:
return (reader.readStringOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
}
}
Id _genericCacheModelGetId(GenericCacheModel object) {
return object.cacheKey ?? Isar.autoIncrement;
}
List<IsarLinkBase<dynamic>> _genericCacheModelGetLinks(
GenericCacheModel object) {
return [];
}
void _genericCacheModelAttach(
IsarCollection<dynamic> col, Id id, GenericCacheModel object) {
object.cacheKey = id;
}
extension GenericCacheModelQueryWhereSort
on QueryBuilder<GenericCacheModel, GenericCacheModel, QWhere> {
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterWhere>
anyCacheKey() {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(const IdWhereClause.any());
});
}
}
extension GenericCacheModelQueryWhere
on QueryBuilder<GenericCacheModel, GenericCacheModel, QWhereClause> {
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterWhereClause>
cacheKeyEqualTo(Id cacheKey) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: cacheKey,
upper: cacheKey,
));
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterWhereClause>
cacheKeyNotEqualTo(Id cacheKey) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(
IdWhereClause.lessThan(upper: cacheKey, includeUpper: false),
)
.addWhereClause(
IdWhereClause.greaterThan(lower: cacheKey, includeLower: false),
);
} else {
return query
.addWhereClause(
IdWhereClause.greaterThan(lower: cacheKey, includeLower: false),
)
.addWhereClause(
IdWhereClause.lessThan(upper: cacheKey, includeUpper: false),
);
}
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterWhereClause>
cacheKeyGreaterThan(Id cacheKey, {bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.greaterThan(lower: cacheKey, includeLower: include),
);
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterWhereClause>
cacheKeyLessThan(Id cacheKey, {bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.lessThan(upper: cacheKey, includeUpper: include),
);
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterWhereClause>
cacheKeyBetween(
Id lowerCacheKey,
Id upperCacheKey, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: lowerCacheKey,
includeLower: includeLower,
upper: upperCacheKey,
includeUpper: includeUpper,
));
});
}
}
extension GenericCacheModelQueryFilter
on QueryBuilder<GenericCacheModel, GenericCacheModel, QFilterCondition> {
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterFilterCondition>
cacheDataIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'cacheData',
));
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterFilterCondition>
cacheDataIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'cacheData',
));
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterFilterCondition>
cacheDataEqualTo(
String? value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'cacheData',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterFilterCondition>
cacheDataGreaterThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'cacheData',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterFilterCondition>
cacheDataLessThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'cacheData',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterFilterCondition>
cacheDataBetween(
String? lower,
String? upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'cacheData',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterFilterCondition>
cacheDataStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'cacheData',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterFilterCondition>
cacheDataEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'cacheData',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterFilterCondition>
cacheDataContains(String value, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'cacheData',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterFilterCondition>
cacheDataMatches(String pattern, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'cacheData',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterFilterCondition>
cacheDataIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'cacheData',
value: '',
));
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterFilterCondition>
cacheDataIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'cacheData',
value: '',
));
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterFilterCondition>
cacheKeyIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'cacheKey',
));
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterFilterCondition>
cacheKeyIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'cacheKey',
));
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterFilterCondition>
cacheKeyEqualTo(Id? value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'cacheKey',
value: value,
));
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterFilterCondition>
cacheKeyGreaterThan(
Id? value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'cacheKey',
value: value,
));
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterFilterCondition>
cacheKeyLessThan(
Id? value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'cacheKey',
value: value,
));
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterFilterCondition>
cacheKeyBetween(
Id? lower,
Id? upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'cacheKey',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
}
extension GenericCacheModelQueryObject
on QueryBuilder<GenericCacheModel, GenericCacheModel, QFilterCondition> {}
extension GenericCacheModelQueryLinks
on QueryBuilder<GenericCacheModel, GenericCacheModel, QFilterCondition> {}
extension GenericCacheModelQuerySortBy
on QueryBuilder<GenericCacheModel, GenericCacheModel, QSortBy> {
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterSortBy>
sortByCacheData() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'cacheData', Sort.asc);
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterSortBy>
sortByCacheDataDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'cacheData', Sort.desc);
});
}
}
extension GenericCacheModelQuerySortThenBy
on QueryBuilder<GenericCacheModel, GenericCacheModel, QSortThenBy> {
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterSortBy>
thenByCacheData() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'cacheData', Sort.asc);
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterSortBy>
thenByCacheDataDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'cacheData', Sort.desc);
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterSortBy>
thenByCacheKey() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'cacheKey', Sort.asc);
});
}
QueryBuilder<GenericCacheModel, GenericCacheModel, QAfterSortBy>
thenByCacheKeyDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'cacheKey', Sort.desc);
});
}
}
extension GenericCacheModelQueryWhereDistinct
on QueryBuilder<GenericCacheModel, GenericCacheModel, QDistinct> {
QueryBuilder<GenericCacheModel, GenericCacheModel, QDistinct>
distinctByCacheData({bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'cacheData', caseSensitive: caseSensitive);
});
}
}
extension GenericCacheModelQueryProperty
on QueryBuilder<GenericCacheModel, GenericCacheModel, QQueryProperty> {
QueryBuilder<GenericCacheModel, int, QQueryOperations> cacheKeyProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'cacheKey');
});
}
QueryBuilder<GenericCacheModel, String?, QQueryOperations>
cacheDataProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'cacheData');
});
}
}

View File

@@ -0,0 +1,29 @@
import 'package:isar/isar.dart';
import '../../debug_helper.dart';
import '../util.dart';
part 'homework_cache_model.g.dart';
@collection
class HomeworkCacheModel extends DatedCacheEntry {
HomeworkCacheModel();
}
Future<void> resetOldHomeworkCache(Isar isar) async {
var now = timeNow();
var weeks = await isar.homeworkCacheModels.where().findAll();
var weeksToRemove = List<Id>.empty(growable: true);
for (var week in weeks) {
var date = getDate(week.cacheKey!);
if (date.millisecondsSinceEpoch <
now.subtract(Duration(days: 120)).millisecondsSinceEpoch) {
weeksToRemove.add(week.cacheKey!);
}
}
await isar.writeTxn(() async {
await isar.homeworkCacheModels.deleteAll(weeksToRemove);
});
}

View File

@@ -0,0 +1,562 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'homework_cache_model.dart';
// **************************************************************************
// IsarCollectionGenerator
// **************************************************************************
// coverage:ignore-file
// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types
extension GetHomeworkCacheModelCollection on Isar {
IsarCollection<HomeworkCacheModel> get homeworkCacheModels =>
this.collection();
}
const HomeworkCacheModelSchema = CollectionSchema(
name: r'HomeworkCacheModel',
id: -356692531669197690,
properties: {
r'values': PropertySchema(
id: 0,
name: r'values',
type: IsarType.stringList,
)
},
estimateSize: _homeworkCacheModelEstimateSize,
serialize: _homeworkCacheModelSerialize,
deserialize: _homeworkCacheModelDeserialize,
deserializeProp: _homeworkCacheModelDeserializeProp,
idName: r'cacheKey',
indexes: {},
links: {},
embeddedSchemas: {},
getId: _homeworkCacheModelGetId,
getLinks: _homeworkCacheModelGetLinks,
attach: _homeworkCacheModelAttach,
version: '3.1.0+1',
);
int _homeworkCacheModelEstimateSize(
HomeworkCacheModel object,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
var bytesCount = offsets.last;
{
final list = object.values;
if (list != null) {
bytesCount += 3 + list.length * 3;
{
for (var i = 0; i < list.length; i++) {
final value = list[i];
bytesCount += value.length * 3;
}
}
}
}
return bytesCount;
}
void _homeworkCacheModelSerialize(
HomeworkCacheModel object,
IsarWriter writer,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
writer.writeStringList(offsets[0], object.values);
}
HomeworkCacheModel _homeworkCacheModelDeserialize(
Id id,
IsarReader reader,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
final object = HomeworkCacheModel();
object.cacheKey = id;
object.values = reader.readStringList(offsets[0]);
return object;
}
P _homeworkCacheModelDeserializeProp<P>(
IsarReader reader,
int propertyId,
int offset,
Map<Type, List<int>> allOffsets,
) {
switch (propertyId) {
case 0:
return (reader.readStringList(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
}
}
Id _homeworkCacheModelGetId(HomeworkCacheModel object) {
return object.cacheKey ?? Isar.autoIncrement;
}
List<IsarLinkBase<dynamic>> _homeworkCacheModelGetLinks(
HomeworkCacheModel object) {
return [];
}
void _homeworkCacheModelAttach(
IsarCollection<dynamic> col, Id id, HomeworkCacheModel object) {
object.cacheKey = id;
}
extension HomeworkCacheModelQueryWhereSort
on QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QWhere> {
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterWhere>
anyCacheKey() {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(const IdWhereClause.any());
});
}
}
extension HomeworkCacheModelQueryWhere
on QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QWhereClause> {
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterWhereClause>
cacheKeyEqualTo(Id cacheKey) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: cacheKey,
upper: cacheKey,
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterWhereClause>
cacheKeyNotEqualTo(Id cacheKey) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(
IdWhereClause.lessThan(upper: cacheKey, includeUpper: false),
)
.addWhereClause(
IdWhereClause.greaterThan(lower: cacheKey, includeLower: false),
);
} else {
return query
.addWhereClause(
IdWhereClause.greaterThan(lower: cacheKey, includeLower: false),
)
.addWhereClause(
IdWhereClause.lessThan(upper: cacheKey, includeUpper: false),
);
}
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterWhereClause>
cacheKeyGreaterThan(Id cacheKey, {bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.greaterThan(lower: cacheKey, includeLower: include),
);
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterWhereClause>
cacheKeyLessThan(Id cacheKey, {bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.lessThan(upper: cacheKey, includeUpper: include),
);
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterWhereClause>
cacheKeyBetween(
Id lowerCacheKey,
Id upperCacheKey, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: lowerCacheKey,
includeLower: includeLower,
upper: upperCacheKey,
includeUpper: includeUpper,
));
});
}
}
extension HomeworkCacheModelQueryFilter
on QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QFilterCondition> {
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
cacheKeyIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'cacheKey',
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
cacheKeyIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'cacheKey',
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
cacheKeyEqualTo(Id? value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'cacheKey',
value: value,
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
cacheKeyGreaterThan(
Id? value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'cacheKey',
value: value,
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
cacheKeyLessThan(
Id? value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'cacheKey',
value: value,
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
cacheKeyBetween(
Id? lower,
Id? upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'cacheKey',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
valuesIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'values',
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
valuesIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'values',
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
valuesElementEqualTo(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'values',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
valuesElementGreaterThan(
String value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'values',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
valuesElementLessThan(
String value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'values',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
valuesElementBetween(
String lower,
String upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'values',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
valuesElementStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'values',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
valuesElementEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'values',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
valuesElementContains(String value, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'values',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
valuesElementMatches(String pattern, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'values',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
valuesElementIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'values',
value: '',
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
valuesElementIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'values',
value: '',
));
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
valuesLengthEqualTo(int length) {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'values',
length,
true,
length,
true,
);
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
valuesIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'values',
0,
true,
0,
true,
);
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
valuesIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'values',
0,
false,
999999,
true,
);
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
valuesLengthLessThan(
int length, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'values',
0,
true,
length,
include,
);
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
valuesLengthGreaterThan(
int length, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'values',
length,
include,
999999,
true,
);
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterFilterCondition>
valuesLengthBetween(
int lower,
int upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'values',
lower,
includeLower,
upper,
includeUpper,
);
});
}
}
extension HomeworkCacheModelQueryObject
on QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QFilterCondition> {}
extension HomeworkCacheModelQueryLinks
on QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QFilterCondition> {}
extension HomeworkCacheModelQuerySortBy
on QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QSortBy> {}
extension HomeworkCacheModelQuerySortThenBy
on QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QSortThenBy> {
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterSortBy>
thenByCacheKey() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'cacheKey', Sort.asc);
});
}
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QAfterSortBy>
thenByCacheKeyDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'cacheKey', Sort.desc);
});
}
}
extension HomeworkCacheModelQueryWhereDistinct
on QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QDistinct> {
QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QDistinct>
distinctByValues() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'values');
});
}
}
extension HomeworkCacheModelQueryProperty
on QueryBuilder<HomeworkCacheModel, HomeworkCacheModel, QQueryProperty> {
QueryBuilder<HomeworkCacheModel, int, QQueryOperations> cacheKeyProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'cacheKey');
});
}
QueryBuilder<HomeworkCacheModel, List<String>?, QQueryOperations>
valuesProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'values');
});
}
}

View File

@@ -1,7 +1,7 @@
import 'package:isar_community/isar.dart'; import 'package:isar/isar.dart';
import 'package:firka/core/debug_helper.dart'; import '../../debug_helper.dart';
import 'package:firka/data/util.dart'; import '../util.dart';
part 'timetable_cache_model.g.dart'; part 'timetable_cache_model.g.dart';

View File

@@ -0,0 +1,562 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'timetable_cache_model.dart';
// **************************************************************************
// IsarCollectionGenerator
// **************************************************************************
// coverage:ignore-file
// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types
extension GetTimetableCacheModelCollection on Isar {
IsarCollection<TimetableCacheModel> get timetableCacheModels =>
this.collection();
}
const TimetableCacheModelSchema = CollectionSchema(
name: r'TimetableCacheModel',
id: -8626340955125680275,
properties: {
r'values': PropertySchema(
id: 0,
name: r'values',
type: IsarType.stringList,
)
},
estimateSize: _timetableCacheModelEstimateSize,
serialize: _timetableCacheModelSerialize,
deserialize: _timetableCacheModelDeserialize,
deserializeProp: _timetableCacheModelDeserializeProp,
idName: r'cacheKey',
indexes: {},
links: {},
embeddedSchemas: {},
getId: _timetableCacheModelGetId,
getLinks: _timetableCacheModelGetLinks,
attach: _timetableCacheModelAttach,
version: '3.1.0+1',
);
int _timetableCacheModelEstimateSize(
TimetableCacheModel object,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
var bytesCount = offsets.last;
{
final list = object.values;
if (list != null) {
bytesCount += 3 + list.length * 3;
{
for (var i = 0; i < list.length; i++) {
final value = list[i];
bytesCount += value.length * 3;
}
}
}
}
return bytesCount;
}
void _timetableCacheModelSerialize(
TimetableCacheModel object,
IsarWriter writer,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
writer.writeStringList(offsets[0], object.values);
}
TimetableCacheModel _timetableCacheModelDeserialize(
Id id,
IsarReader reader,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
final object = TimetableCacheModel();
object.cacheKey = id;
object.values = reader.readStringList(offsets[0]);
return object;
}
P _timetableCacheModelDeserializeProp<P>(
IsarReader reader,
int propertyId,
int offset,
Map<Type, List<int>> allOffsets,
) {
switch (propertyId) {
case 0:
return (reader.readStringList(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
}
}
Id _timetableCacheModelGetId(TimetableCacheModel object) {
return object.cacheKey ?? Isar.autoIncrement;
}
List<IsarLinkBase<dynamic>> _timetableCacheModelGetLinks(
TimetableCacheModel object) {
return [];
}
void _timetableCacheModelAttach(
IsarCollection<dynamic> col, Id id, TimetableCacheModel object) {
object.cacheKey = id;
}
extension TimetableCacheModelQueryWhereSort
on QueryBuilder<TimetableCacheModel, TimetableCacheModel, QWhere> {
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterWhere>
anyCacheKey() {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(const IdWhereClause.any());
});
}
}
extension TimetableCacheModelQueryWhere
on QueryBuilder<TimetableCacheModel, TimetableCacheModel, QWhereClause> {
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterWhereClause>
cacheKeyEqualTo(Id cacheKey) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: cacheKey,
upper: cacheKey,
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterWhereClause>
cacheKeyNotEqualTo(Id cacheKey) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(
IdWhereClause.lessThan(upper: cacheKey, includeUpper: false),
)
.addWhereClause(
IdWhereClause.greaterThan(lower: cacheKey, includeLower: false),
);
} else {
return query
.addWhereClause(
IdWhereClause.greaterThan(lower: cacheKey, includeLower: false),
)
.addWhereClause(
IdWhereClause.lessThan(upper: cacheKey, includeUpper: false),
);
}
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterWhereClause>
cacheKeyGreaterThan(Id cacheKey, {bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.greaterThan(lower: cacheKey, includeLower: include),
);
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterWhereClause>
cacheKeyLessThan(Id cacheKey, {bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.lessThan(upper: cacheKey, includeUpper: include),
);
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterWhereClause>
cacheKeyBetween(
Id lowerCacheKey,
Id upperCacheKey, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: lowerCacheKey,
includeLower: includeLower,
upper: upperCacheKey,
includeUpper: includeUpper,
));
});
}
}
extension TimetableCacheModelQueryFilter on QueryBuilder<TimetableCacheModel,
TimetableCacheModel, QFilterCondition> {
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
cacheKeyIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'cacheKey',
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
cacheKeyIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'cacheKey',
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
cacheKeyEqualTo(Id? value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'cacheKey',
value: value,
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
cacheKeyGreaterThan(
Id? value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'cacheKey',
value: value,
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
cacheKeyLessThan(
Id? value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'cacheKey',
value: value,
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
cacheKeyBetween(
Id? lower,
Id? upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'cacheKey',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
valuesIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'values',
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
valuesIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'values',
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
valuesElementEqualTo(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'values',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
valuesElementGreaterThan(
String value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'values',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
valuesElementLessThan(
String value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'values',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
valuesElementBetween(
String lower,
String upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'values',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
valuesElementStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'values',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
valuesElementEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'values',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
valuesElementContains(String value, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'values',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
valuesElementMatches(String pattern, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'values',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
valuesElementIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'values',
value: '',
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
valuesElementIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'values',
value: '',
));
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
valuesLengthEqualTo(int length) {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'values',
length,
true,
length,
true,
);
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
valuesIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'values',
0,
true,
0,
true,
);
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
valuesIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'values',
0,
false,
999999,
true,
);
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
valuesLengthLessThan(
int length, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'values',
0,
true,
length,
include,
);
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
valuesLengthGreaterThan(
int length, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'values',
length,
include,
999999,
true,
);
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterFilterCondition>
valuesLengthBetween(
int lower,
int upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'values',
lower,
includeLower,
upper,
includeUpper,
);
});
}
}
extension TimetableCacheModelQueryObject on QueryBuilder<TimetableCacheModel,
TimetableCacheModel, QFilterCondition> {}
extension TimetableCacheModelQueryLinks on QueryBuilder<TimetableCacheModel,
TimetableCacheModel, QFilterCondition> {}
extension TimetableCacheModelQuerySortBy
on QueryBuilder<TimetableCacheModel, TimetableCacheModel, QSortBy> {}
extension TimetableCacheModelQuerySortThenBy
on QueryBuilder<TimetableCacheModel, TimetableCacheModel, QSortThenBy> {
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterSortBy>
thenByCacheKey() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'cacheKey', Sort.asc);
});
}
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QAfterSortBy>
thenByCacheKeyDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'cacheKey', Sort.desc);
});
}
}
extension TimetableCacheModelQueryWhereDistinct
on QueryBuilder<TimetableCacheModel, TimetableCacheModel, QDistinct> {
QueryBuilder<TimetableCacheModel, TimetableCacheModel, QDistinct>
distinctByValues() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'values');
});
}
}
extension TimetableCacheModelQueryProperty
on QueryBuilder<TimetableCacheModel, TimetableCacheModel, QQueryProperty> {
QueryBuilder<TimetableCacheModel, int, QQueryOperations> cacheKeyProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'cacheKey');
});
}
QueryBuilder<TimetableCacheModel, List<String>?, QQueryOperations>
valuesProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'values');
});
}
}

View File

@@ -2,10 +2,11 @@ import 'dart:convert';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:kreta_api/kreta_api.dart'; import 'package:firka/helpers/api/resp/token_grant.dart';
import 'package:firka/core/debug_helper.dart'; import 'package:firka/helpers/extensions.dart';
import 'package:firka/core/extensions.dart'; import 'package:isar/isar.dart';
import 'package:isar_community/isar.dart';
import '../../debug_helper.dart';
part 'token_model.g.dart'; part 'token_model.g.dart';
@@ -63,8 +64,7 @@ class TokenModel {
// you would expect all usernames to be numeric // you would expect all usernames to be numeric
// and for them be the student's student id, but NO // and for them be the student's student id, but NO
final hash = sha256.convert(utf8.encode(username)); final hash = sha256.convert(utf8.encode(username));
final value = final value = ((hash.bytes[0] << 24) |
((hash.bytes[0] << 24) |
(hash.bytes[1] << 16) | (hash.bytes[1] << 16) |
(hash.bytes[2] << 8) | (hash.bytes[2] << 8) |
(hash.bytes[3])) >>> (hash.bytes[3])) >>>

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
import 'dart:math'; import 'dart:math';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:isar_community/isar.dart'; import 'package:isar/isar.dart';
import 'package:firka/core/debug_helper.dart'; import '../debug_helper.dart';
class DatedCacheEntry { class DatedCacheEntry {
Id? cacheKey; Id? cacheKey;

View File

@@ -1,17 +1,18 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:firka/api/client/kreta_client.dart'; import 'package:firka/helpers/api/client/kreta_client.dart';
import 'package:kreta_api/kreta_api.dart'; import 'package:firka/helpers/api/model/grade.dart';
import 'package:firka/core/debug_helper.dart'; import 'package:firka/helpers/api/model/timetable.dart';
import 'package:firka/data/ios_widget_helper.dart'; import 'package:firka/helpers/db/ios_widget_helper.dart';
import 'package:firka/core/settings.dart'; import 'package:firka/helpers/debug_helper.dart';
import 'package:firka/helpers/settings.dart';
import 'package:firka/main.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:firka/ui/theme/style.dart'; import '../../ui/model/style.dart';
class WidgetCacheHelper { class WidgetCacheHelper {
static Map<String, dynamic> toJson(FirkaStyle style, List<Lesson> timetable) { static Map<String, dynamic> toJson(FirkaStyle style, List<Lesson> timetable) {
@@ -21,63 +22,42 @@ class WidgetCacheHelper {
timetableJson.add(lesson.toJson()); timetableJson.add(lesson.toJson());
} }
return {'colors': _colorsMap(style), 'timetable': timetableJson};
}
static Map<String, dynamic> toAndroidWidgetJson(
FirkaStyle style,
List<Lesson> timetable,
) {
final timetableJson = <Map<String, dynamic>>[];
for (var lesson in timetable) {
timetableJson.add({
'Nev': lesson.name,
'KezdetIdopont': lesson.start.toUtc().toIso8601String(),
'VegIdopont': lesson.end.toUtc().toIso8601String(),
'Oraszam': lesson.lessonNumber,
'TeremNeve': lesson.roomName,
'HelyettesTanarNeve': lesson.substituteTeacher,
});
}
return {'colors': _colorsMap(style), 'timetable': timetableJson};
}
static Map<String, dynamic> _colorsMap(FirkaStyle style) {
return { return {
'background': style.colors.background.toARGB32(), 'colors': {
'backgroundAmoled': style.colors.backgroundAmoled.toARGB32(), 'background': style.colors.background.toARGB32(),
'background0p': style.colors.background0p.toARGB32(), 'backgroundAmoled': style.colors.backgroundAmoled.toARGB32(),
'success': style.colors.success.toARGB32(), 'background0p': style.colors.background0p.toARGB32(),
'textPrimary': style.colors.textPrimary.toARGB32(), 'success': style.colors.success.toARGB32(),
'textSecondary': style.colors.textSecondary.toARGB32(), 'textPrimary': style.colors.textPrimary.toARGB32(),
'textTertiary': style.colors.textTertiary.toARGB32(), 'textSecondary': style.colors.textSecondary.toARGB32(),
'card': style.colors.card.toARGB32(), 'textTertiary': style.colors.textTertiary.toARGB32(),
'cardTranslucent': style.colors.cardTranslucent.toARGB32(), 'card': style.colors.card.toARGB32(),
'buttonSecondaryFill': style.colors.buttonSecondaryFill.toARGB32(), 'cardTranslucent': style.colors.cardTranslucent.toARGB32(),
'accent': style.colors.accent.toARGB32(), 'buttonSecondaryFill': style.colors.buttonSecondaryFill.toARGB32(),
'secondary': style.colors.secondary.toARGB32(), 'accent': style.colors.accent.toARGB32(),
'shadowColor': style.colors.shadowColor.toARGB32(), 'secondary': style.colors.secondary.toARGB32(),
'a15p': style.colors.a15p.toARGB32(), 'shadowColor': style.colors.shadowColor.toARGB32(),
'warningAccent': style.colors.warningAccent.toARGB32(), 'a15p': style.colors.a15p.toARGB32(),
'warningText': style.colors.warningText.toARGB32(), 'warningAccent': style.colors.warningAccent.toARGB32(),
'warning15p': style.colors.warning15p.toARGB32(), 'warningText': style.colors.warningText.toARGB32(),
'warningCard': style.colors.warningCard.toARGB32(), 'warning15p': style.colors.warning15p.toARGB32(),
'errorAccent': style.colors.errorAccent.toARGB32(), 'warningCard': style.colors.warningCard.toARGB32(),
'errorText': style.colors.errorText.toARGB32(), 'errorAccent': style.colors.errorAccent.toARGB32(),
'error15p': style.colors.error15p.toARGB32(), 'errorText': style.colors.errorText.toARGB32(),
'errorCard': style.colors.errorCard.toARGB32(), 'error15p': style.colors.error15p.toARGB32(),
'grade5': style.colors.grade5.toARGB32(), 'errorCard': style.colors.errorCard.toARGB32(),
'grade4': style.colors.grade4.toARGB32(), 'grade5': style.colors.grade5.toARGB32(),
'grade3': style.colors.grade3.toARGB32(), 'grade4': style.colors.grade4.toARGB32(),
'grade2': style.colors.grade2.toARGB32(), 'grade3': style.colors.grade3.toARGB32(),
'grade1': style.colors.grade1.toARGB32(), 'grade2': style.colors.grade2.toARGB32(),
'grade1': style.colors.grade1.toARGB32(),
},
'timetable': timetableJson,
}; };
} }
static Future<void> updateWidgetCache( static Future<void> updateWidgetCache(
FirkaStyle style, FirkaStyle style, KretaClient client) async {
KretaClient client,
) async {
final dataDir = await getApplicationDocumentsDirectory(); final dataDir = await getApplicationDocumentsDirectory();
final now = timeNow(); final now = timeNow();
@@ -89,40 +69,14 @@ class WidgetCacheHelper {
final widgetFile = File(p.join(dataDir.path, "widget_state.json")); final widgetFile = File(p.join(dataDir.path, "widget_state.json"));
if (lessons.response != null) { if (lessons.response != null) {
debugPrint( debugPrint('Android widget cache: ${lessons.response!.length} lessons (cached: ${lessons.cached})');
'Android widget cache: ${lessons.response!.length} lessons (cached: ${lessons.cached})',
);
widgetFile.writeAsString( widgetFile.writeAsString(
jsonEncode(WidgetCacheHelper.toJson(style, lessons.response!)), jsonEncode(WidgetCacheHelper.toJson(style, lessons.response!)));
);
} else { } else {
debugPrint('Android widget cache: No lessons to cache'); debugPrint('Android widget cache: No lessons to cache');
} }
} }
static Future<void> generateWidgetStateForDate(
DateTime date,
FirkaStyle style,
KretaClient client,
) async {
final dataDir = await getApplicationDocumentsDirectory();
final dayStart = DateTime(date.year, date.month, date.day);
final dayEnd = dayStart.add(Duration(hours: 23, minutes: 59));
final lessons = await client.getTimeTable(
dayStart,
dayEnd,
forceCache: false,
);
final dayLessons = lessons.response ?? [];
final json = toJson(style, dayLessons);
json['displayDate'] =
'${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
final widgetFile = File(p.join(dataDir.path, "widget_state.json"));
await widgetFile.writeAsString(jsonEncode(json));
}
static Future<void> updateIOSWidgets({ static Future<void> updateIOSWidgets({
required String locale, required String locale,
required String theme, required String theme,
@@ -151,17 +105,13 @@ class WidgetCacheHelper {
/// Comprehensive iOS widget refresh that collects all necessary data /// Comprehensive iOS widget refresh that collects all necessary data
/// Call this on: app open, user switch, data refresh /// Call this on: app open, user switch, data refresh
static Future<void> refreshIOSWidgets( static Future<void> refreshIOSWidgets(KretaClient client, SettingsStore settings) async {
KretaClient client,
SettingsStore settings,
) async {
if (!Platform.isIOS) return; if (!Platform.isIOS) return;
try { try {
final langIndex = final langIndex = (settings.group("settings").subGroup("application")["language"]
(settings.group("settings").subGroup("application")["language"] as SettingsItemsRadio)
as SettingsItemsRadio) .activeIndex;
.activeIndex;
String locale; String locale;
switch (langIndex) { switch (langIndex) {
case 1: case 1:
@@ -177,10 +127,9 @@ class WidgetCacheHelper {
locale = 'hu'; locale = 'hu';
} }
final themeIndex = final themeIndex = (settings.group("settings").subGroup("customization")["theme"]
(settings.group("settings").subGroup("customization")["theme"] as SettingsItemsRadio)
as SettingsItemsRadio) .activeIndex;
.activeIndex;
String theme; String theme;
switch (themeIndex) { switch (themeIndex) {
case 1: case 1:
@@ -190,11 +139,7 @@ class WidgetCacheHelper {
theme = 'dark'; theme = 'dark';
break; break;
default: default:
theme = theme = isLightMode.value ? 'light' : 'dark';
SchedulerBinding.instance.platformDispatcher.platformBrightness ==
Brightness.light
? 'light'
: 'dark';
} }
final now = timeNow(); final now = timeNow();
@@ -215,9 +160,7 @@ class WidgetCacheHelper {
final todayLessons = todayResponse.response ?? []; final todayLessons = todayResponse.response ?? [];
final tomorrowLessons = tomorrowResponse.response ?? []; final tomorrowLessons = tomorrowResponse.response ?? [];
debugPrint( debugPrint('iOS widget refresh: ${todayLessons.length} today lessons, ${tomorrowLessons.length} tomorrow lessons');
'iOS widget refresh: ${todayLessons.length} today lessons, ${tomorrowLessons.length} tomorrow lessons',
);
List<Lesson> nextSchoolDayLessons = []; List<Lesson> nextSchoolDayLessons = [];
DateTime? nextSchoolDayDate; DateTime? nextSchoolDayDate;
@@ -233,9 +176,7 @@ class WidgetCacheHelper {
if (dayLessons.isNotEmpty) { if (dayLessons.isNotEmpty) {
nextSchoolDayLessons = dayLessons; nextSchoolDayLessons = dayLessons;
nextSchoolDayDate = dayMidnight; nextSchoolDayDate = dayMidnight;
debugPrint( debugPrint('iOS widget: Next school day found ${i} days ahead with ${dayLessons.length} lessons');
'iOS widget: Next school day found $i days ahead with ${dayLessons.length} lessons',
);
break; break;
} }
} }
@@ -244,9 +185,7 @@ class WidgetCacheHelper {
final gradesResponse = await client.getGrades(forceCache: false); final gradesResponse = await client.getGrades(forceCache: false);
final grades = gradesResponse.response ?? []; final grades = gradesResponse.response ?? [];
debugPrint( debugPrint('iOS widget refresh: ${grades.length} grades fetched (cached: ${gradesResponse.cached})');
'iOS widget refresh: ${grades.length} grades fetched (cached: ${gradesResponse.cached})',
);
final Map<String, double> subjectAverages = {}; final Map<String, double> subjectAverages = {};
final Set<String> subjectUids = {}; final Set<String> subjectUids = {};
@@ -259,9 +198,7 @@ class WidgetCacheHelper {
int validSubjectCount = 0; int validSubjectCount = 0;
for (var uid in subjectUids) { for (var uid in subjectUids) {
final subjectGrades = grades final subjectGrades = grades.where((g) => g.subject.uid == uid).toList();
.where((g) => g.subject.uid == uid)
.toList();
final avg = _calculateWeightedAverage(subjectGrades); final avg = _calculateWeightedAverage(subjectGrades);
if (!avg.isNaN && avg > 0) { if (!avg.isNaN && avg > 0) {
subjectAverages[uid] = avg; subjectAverages[uid] = avg;

View File

@@ -1,8 +1,8 @@
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:kreta_api/kreta_api.dart'; import '../l10n/app_localizations.dart';
import 'package:firka/core/debug_helper.dart'; import 'api/model/timetable.dart';
import 'package:firka/l10n/app_localizations.dart'; import 'debug_helper.dart';
extension TimetableExtension on Iterable<Lesson> { extension TimetableExtension on Iterable<Lesson> {
List<Lesson> getAllSeqs(Lesson reference) { List<Lesson> getAllSeqs(Lesson reference) {
@@ -12,43 +12,29 @@ extension TimetableExtension on Iterable<Lesson> {
if (lesson.lessonNumber == null) continue; if (lesson.lessonNumber == null) continue;
if (lessons.firstWhereOrNull( if (lessons.firstWhereOrNull(
(lesson2) => lesson.lessonNumber == lesson2.lessonNumber, (lesson2) => lesson.lessonNumber == lesson2.lessonNumber) ==
) ==
null) { null) {
final ref = reference.start; final ref = reference.start;
final newStart = DateTime( final newStart = DateTime(ref.year, ref.month, ref.day,
ref.year, lesson.start.hour, lesson.start.minute, lesson.start.second);
ref.month, final newEnd = DateTime(ref.year, ref.month, ref.day, lesson.end.hour,
ref.day, lesson.end.minute, lesson.end.second);
lesson.start.hour,
lesson.start.minute,
lesson.start.second,
);
final newEnd = DateTime(
ref.year,
ref.month,
ref.day,
lesson.end.hour,
lesson.end.minute,
lesson.end.second,
);
final lessonCopy = Lesson( final lessonCopy = Lesson(
uid: lesson.uid, uid: lesson.uid,
date: lesson.date, date: lesson.date,
start: newStart, start: newStart,
end: newEnd, end: newEnd,
name: lesson.name, name: lesson.name,
type: lesson.type, type: lesson.type,
state: lesson.state, state: lesson.state,
canStudentEditHomework: lesson.canStudentEditHomework, canStudentEditHomework: lesson.canStudentEditHomework,
isHomeworkComplete: lesson.isHomeworkComplete, isHomeworkComplete: lesson.isHomeworkComplete,
attachments: lesson.attachments, attachments: lesson.attachments,
lessonNumber: lesson.lessonNumber, lessonNumber: lesson.lessonNumber,
isDigitalLesson: lesson.isDigitalLesson, isDigitalLesson: lesson.isDigitalLesson,
digitalSupportDeviceTypeList: lesson.digitalSupportDeviceTypeList, digitalSupportDeviceTypeList: lesson.digitalSupportDeviceTypeList,
createdAt: lesson.createdAt, createdAt: lesson.createdAt,
lastModifiedAt: lesson.lastModifiedAt, lastModifiedAt: lesson.lastModifiedAt);
);
lessons.add(lessonCopy); lessons.add(lessonCopy);
} }
} }
@@ -99,7 +85,7 @@ enum FormatMode {
yyyymmddwedd, yyyymmddwedd,
yyyymmmm, yyyymmmm,
yyyymmdd, yyyymmdd,
yyyymmddhhmmss, yyyymmddhhmmss
} }
enum Cycle { morning, day, afternoon, night } enum Cycle { morning, day, afternoon, night }
@@ -119,12 +105,7 @@ extension DateExtension on DateTime {
switch (mode) { switch (mode) {
case FormatMode.grades: case FormatMode.grades:
if (isBefore(yesterdayLim)) { if (isBefore(yesterdayLim)) {
final month = DateFormat( return format(l10n, FormatMode.yearly);
'MMMM',
l10n.localeName,
).format(this).firstUpper();
final day = DateFormat('d', l10n.localeName).format(this);
return "$month $day";
} }
if (isAfter(yesterdayLim) && isBefore(today)) { if (isAfter(yesterdayLim) && isBefore(today)) {
return l10n.yesterday; return l10n.yesterday;
@@ -142,23 +123,14 @@ extension DateExtension on DateTime {
case FormatMode.hmm: case FormatMode.hmm:
return DateFormat('H:mm', l10n.localeName).format(this); return DateFormat('H:mm', l10n.localeName).format(this);
case FormatMode.welcome: case FormatMode.welcome:
final dayName = DateFormat( return DateFormat('EEE, MMM d', l10n.localeName).format(this);
'EEEE',
l10n.localeName,
).format(this).firstUpper();
final monthAbbr = DateFormat(
'MMM',
l10n.localeName,
).format(this).firstUpper();
final day = DateFormat('d', l10n.localeName).format(this);
return "$dayName, $monthAbbr $day";
case FormatMode.d: case FormatMode.d:
return DateFormat('d', l10n.localeName).format(this); return DateFormat('d', l10n.localeName).format(this);
case FormatMode.da: case FormatMode.da:
return DateFormat( return DateFormat('EEEE', l10n.localeName)
'EEEE', .format(this)
l10n.localeName, .substring(0, 2)
).format(this).substring(0, 2).firstUpper(); .firstUpper();
case FormatMode.dd: case FormatMode.dd:
return DateFormat('dd', l10n.localeName).format(this); return DateFormat('dd', l10n.localeName).format(this);
case FormatMode.yyyymmddwedd: case FormatMode.yyyymmddwedd:
@@ -190,15 +162,12 @@ extension DateExtension on DateTime {
} }
DateTime getMidnight() { DateTime getMidnight() {
return subtract( return subtract(Duration(
Duration(
hours: hour, hours: hour,
minutes: minute, minutes: minute,
seconds: second, seconds: second,
milliseconds: millisecond, milliseconds: millisecond,
microseconds: microsecond, microseconds: microsecond));
),
);
} }
Cycle getDayCycle() { Cycle getDayCycle() {
@@ -225,28 +194,22 @@ extension DateGrouper<T> on Iterable<T> {
Map<DateTime, List<T>> newList = {}; Map<DateTime, List<T>> newList = {};
var today = timeNow(); var today = timeNow();
today = today.subtract( today = today.subtract(Duration(
Duration(
hours: today.hour, hours: today.hour,
minutes: today.minute, minutes: today.minute,
seconds: today.second, seconds: today.second,
milliseconds: today.millisecond, milliseconds: today.millisecond));
),
);
var tomorrow = today.add(Duration(days: 1)); var tomorrow = today.add(Duration(days: 1));
var yesterday = today.subtract(Duration(days: 1)); var yesterday = today.subtract(Duration(days: 1));
for (var elem in this) { for (var elem in this) {
var date = getDate(elem); var date = getDate(elem);
var day = date.subtract( var day = date.subtract(Duration(
Duration(
hours: date.hour, hours: date.hour,
minutes: date.minute, minutes: date.minute,
seconds: date.second, seconds: date.second,
milliseconds: date.millisecond, milliseconds: date.millisecond));
),
);
if (date.isAfter(tomorrow.add(Duration(days: 1)))) { if (date.isAfter(tomorrow.add(Duration(days: 1)))) {
if (newList[day] == null) { if (newList[day] == null) {
@@ -292,20 +255,17 @@ extension LessonExtension on List<Lesson> {
Lesson? getCurrentLesson(DateTime now) { Lesson? getCurrentLesson(DateTime now) {
return firstWhereOrNull( return firstWhereOrNull(
(lesson) => now.isAfter(lesson.start) && now.isBefore(lesson.end), (lesson) => now.isAfter(lesson.start) && now.isBefore(lesson.end));
);
} }
Lesson? getPrevLesson(DateTime now) { Lesson? getPrevLesson(DateTime now) {
return firstWhereOrNull( return firstWhereOrNull(
(lesson) => lesson.end.isBefore(now.add(Duration(milliseconds: 1))), (lesson) => lesson.end.isBefore(now.add(Duration(milliseconds: 1))));
);
} }
Lesson? getNextLesson(DateTime now) { Lesson? getNextLesson(DateTime now) {
return firstWhereOrNull( return firstWhereOrNull(
(lesson) => lesson.start.isAfter(now.add(Duration(milliseconds: 1))), (lesson) => lesson.start.isAfter(now.add(Duration(milliseconds: 1))));
);
} }
} }

View File

@@ -3,7 +3,7 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:brotli/brotli.dart'; import 'package:brotli/brotli.dart';
import 'package:firka/app/app_state.dart'; import 'package:firka/main.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
class FirkaBundle extends CachingAssetBundle { class FirkaBundle extends CachingAssetBundle {
@@ -34,9 +34,8 @@ class FirkaBundle extends CachingAssetBundle {
@override @override
Future<ByteData> load(String key) async { Future<ByteData> load(String key) async {
if (!_compressedBundle) { if (!_compressedBundle) {
logger.finest( logger
"Loading asset from root bundle: assets/flutter_assets/$key", .finest("Loading asset from root bundle: assets/flutter_assets/$key");
);
return rootBundle.load(key); return rootBundle.load(key);
} else { } else {
index ??= await loadIndex(); index ??= await loadIndex();
@@ -44,8 +43,7 @@ class FirkaBundle extends CachingAssetBundle {
final gzip = GZipCodec(); final gzip = GZipCodec();
logger.finest( logger.finest(
"Loading asset from firka bundle: assets/flutter_assets/$key", "Loading asset from firka bundle: assets/flutter_assets/$key");
);
switch (index!["assets/flutter_assets/$key"]!) { switch (index!["assets/flutter_assets/$key"]!) {
case "b": // brotli case "b": // brotli
return decode(brotli, await rootBundle.load(key)); return decode(brotli, await rootBundle.load(key));

View File

@@ -0,0 +1,32 @@
import 'package:firka/main.dart';
import 'package:flutter/widgets.dart';
abstract class FirkaState<T extends StatefulWidget> extends State<T> {
@override
@mustCallSuper
void initState() {
super.initState();
globalUpdate.addListener(_doUpdate);
}
void _doUpdate() {
if (mounted) setState(() {});
}
@override
@mustCallSuper
void didChangeDependencies() {
super.didChangeDependencies();
globalUpdate.removeListener(_doUpdate);
globalUpdate.addListener(_doUpdate);
}
@override
@mustCallSuper
void dispose() {
super.dispose();
globalUpdate.removeListener(_doUpdate);
}
}

View File

@@ -34,7 +34,7 @@ enum ClassIcon {
linux, linux,
database, database,
applications, applications,
project, project
} }
Map<ClassIcon, RegExp> _descriptors = { Map<ClassIcon, RegExp> _descriptors = {
@@ -49,9 +49,8 @@ Map<ClassIcon, RegExp> _descriptors = {
ClassIcon.pe: RegExp(r'^tes(i|tneveles)|sport|edzeselmelet'), ClassIcon.pe: RegExp(r'^tes(i|tneveles)|sport|edzeselmelet'),
ClassIcon.chemistry: RegExp(r'kemia'), ClassIcon.chemistry: RegExp(r'kemia'),
ClassIcon.biology: RegExp(r'biologia'), ClassIcon.biology: RegExp(r'biologia'),
ClassIcon.env: RegExp( ClassIcon.env:
r'kornyezet|termeszet ?(tudomany|ismeret)|hon( es nep)?ismeret', RegExp(r'kornyezet|termeszet ?(tudomany|ismeret)|hon( es nep)?ismeret'),
),
ClassIcon.religion: RegExp(r'(hit|erkolcs)tan|vallas|etika|bibliaismeret'), ClassIcon.religion: RegExp(r'(hit|erkolcs)tan|vallas|etika|bibliaismeret'),
ClassIcon.economics: RegExp(r'penzugy|gazdasag'), ClassIcon.economics: RegExp(r'penzugy|gazdasag'),
ClassIcon.it: RegExp(r'informatika|szoftver|iroda|digitalis'), ClassIcon.it: RegExp(r'informatika|szoftver|iroda|digitalis'),
@@ -67,13 +66,12 @@ Map<ClassIcon, RegExp> _descriptors = {
ClassIcon.ofo: RegExp(r'osztaly(fonoki|kozosseg)|kozossegi|neveles'), ClassIcon.ofo: RegExp(r'osztaly(fonoki|kozosseg)|kozossegi|neveles'),
ClassIcon.diligence: RegExp(r'szorgalom'), ClassIcon.diligence: RegExp(r'szorgalom'),
ClassIcon.attitude: RegExp(r'magatartas'), ClassIcon.attitude: RegExp(r'magatartas'),
ClassIcon.language: RegExp( ClassIcon.language:
r'angol|nemet|francia|olasz|orosz|spanyol|latin|kinai|nyelv', RegExp(r'angol|nemet|francia|olasz|orosz|spanyol|latin|kinai|nyelv'),
),
ClassIcon.linux: RegExp(r'linux'), ClassIcon.linux: RegExp(r'linux'),
ClassIcon.database: RegExp(r'adatbazis.*'), ClassIcon.database: RegExp(r'adatbazis.*'),
ClassIcon.applications: RegExp(r'asztali alkalmazasok'), ClassIcon.applications: RegExp(r'asztali alkalmazasok'),
ClassIcon.project: RegExp(r'projekt'), ClassIcon.project: RegExp(r'projekt')
}; };
Map<ClassIcon, Uint8List> _iconMap = { Map<ClassIcon, Uint8List> _iconMap = {
@@ -119,19 +117,17 @@ ClassIcon? getIconType(String uid, String className, String category) {
if (icon == null) { if (icon == null) {
for (var desc in _descriptors.entries) { for (var desc in _descriptors.entries) {
if (desc.value.hasMatch( if (desc.value.hasMatch(className
className .replaceAll("ö", "o")
.replaceAll("ö", "o") .replaceAll("ü", "u")
.replaceAll("ü", "u") .replaceAll("ó", "o")
.replaceAll("ó", "o") .replaceAll("ő", "o")
.replaceAll("ő", "o") .replaceAll("ú", "u")
.replaceAll("ú", "u") .replaceAll("é", "e")
.replaceAll("é", "e") .replaceAll("á", "a")
.replaceAll("á", "a") .replaceAll("ű", "u")
.replaceAll("ű", "u") .replaceAll("í", "i")
.replaceAll("í", "i") .toLowerCase())) {
.toLowerCase(),
)) {
icon = desc.key; icon = desc.key;
break; break;

View File

@@ -1,6 +1,6 @@
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:firka/app/app_state.dart'; import 'package:firka/main.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -10,9 +10,7 @@ class ImagePreloader {
static final Map<String, Future<ui.Image>> _loadingFutures = {}; static final Map<String, Future<ui.Image>> _loadingFutures = {};
static Future<ui.Image> preloadAssetImage( static Future<ui.Image> preloadAssetImage(
AssetBundle bundle, AssetBundle bundle, String assetPath) async {
String assetPath,
) async {
if (_cache.containsKey(assetPath)) { if (_cache.containsKey(assetPath)) {
return _cache[assetPath]!; return _cache[assetPath]!;
} }
@@ -34,12 +32,9 @@ class ImagePreloader {
} }
static Future<List<ui.Image>> preloadMultipleAssets( static Future<List<ui.Image>> preloadMultipleAssets(
AssetBundle bundle, AssetBundle bundle, List<String> assetPaths) async {
List<String> assetPaths, final futures =
) async { assetPaths.map((path) => preloadAssetImage(bundle, path)).toList();
final futures = assetPaths
.map((path) => preloadAssetImage(bundle, path))
.toList();
return await Future.wait(futures); return await Future.wait(futures);
} }
@@ -96,9 +91,7 @@ class ImagePreloader {
} }
static Future<ui.Image> _loadAssetImage( static Future<ui.Image> _loadAssetImage(
AssetBundle bundle, AssetBundle bundle, String assetPath) async {
String assetPath,
) async {
logger.finest("Caching: $assetPath"); logger.finest("Caching: $assetPath");
final ByteData data = await bundle.load(assetPath); final ByteData data = await bundle.load(assetPath);
final Uint8List bytes = data.buffer.asUint8List(); final Uint8List bytes = data.buffer.asUint8List();
@@ -126,9 +119,7 @@ class PreloadedImageProvider extends ImageProvider<PreloadedImageProvider> {
@override @override
ImageStreamCompleter loadImage( ImageStreamCompleter loadImage(
PreloadedImageProvider key, PreloadedImageProvider key, ImageDecoderCallback decode) {
ImageDecoderCallback decode,
) {
return OneFrameImageStreamCompleter(_loadAsync(key)); return OneFrameImageStreamCompleter(_loadAsync(key));
} }
@@ -142,10 +133,8 @@ class PreloadedImageProvider extends ImageProvider<PreloadedImageProvider> {
} }
try { try {
final image = await ImagePreloader.preloadAssetImage( final image =
assetBundle, await ImagePreloader.preloadAssetImage(assetBundle, key.assetPath);
key.assetPath,
);
return ImageInfo(image: image.clone()); return ImageInfo(image: image.clone());
} catch (e) { } catch (e) {
final ByteData data = await assetBundle.load(key.assetPath); final ByteData data = await assetBundle.load(key.assetPath);

View File

@@ -1,13 +1,11 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:kreta_api/kreta_api.dart'; import 'package:firka/helpers/api/model/timetable.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
class LiveActivityManager { class LiveActivityManager {
static const MethodChannel _channel = MethodChannel( static const MethodChannel _channel = MethodChannel('firka.app/live_activity');
'firka.app/live_activity',
);
static final Logger _logger = Logger('LiveActivityManager'); static final Logger _logger = Logger('LiveActivityManager');
static String? _activityId; static String? _activityId;
@@ -33,9 +31,7 @@ class LiveActivityManager {
if (activeActivities.isNotEmpty) { if (activeActivities.isNotEmpty) {
_activityId = activeActivities.first; _activityId = activeActivities.first;
_isActivityActive = true; _isActivityActive = true;
_logger.info( _logger.info('Synced activity state: Found existing activity $_activityId');
'Synced activity state: Found existing activity $_activityId',
);
} else { } else {
_activityId = null; _activityId = null;
_isActivityActive = false; _isActivityActive = false;
@@ -52,9 +48,7 @@ class LiveActivityManager {
final args = call.arguments as Map; final args = call.arguments as Map;
final activityId = args['activityId'] as String; final activityId = args['activityId'] as String;
final pushToken = args['pushToken'] as String; final pushToken = args['pushToken'] as String;
_logger.info( _logger.info('Received LiveActivity push token: ${pushToken.substring(0, 10)}...');
'Received LiveActivity push token: ${pushToken.substring(0, 10)}...',
);
_onPushTokenReceived?.call(activityId, pushToken); _onPushTokenReceived?.call(activityId, pushToken);
break; break;
default: default:
@@ -62,9 +56,7 @@ class LiveActivityManager {
} }
} }
static void setOnPushTokenReceived( static void setOnPushTokenReceived(Function(String activityId, String pushToken) callback) {
Function(String activityId, String pushToken) callback,
) {
_onPushTokenReceived = callback; _onPushTokenReceived = callback;
} }
@@ -100,9 +92,7 @@ class LiveActivityManager {
try { try {
await _syncActivityState(); await _syncActivityState();
if (_isActivityActive) { if (_isActivityActive) {
_logger.info( _logger.info('Activity already exists, ending it to create new one with fresh token');
'Activity already exists, ending it to create new one with fresh token',
);
await endAllActivities(); await endAllActivities();
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
} }
@@ -227,23 +217,18 @@ class LiveActivityManager {
final payload = { final payload = {
'isBreak': isBeforeSchool ? false : isBreak, 'isBreak': isBeforeSchool ? false : isBreak,
'lessonName': isBeforeSchool 'lessonName': isBeforeSchool ? currentLesson.name : (isBreak ? 'Szünet' : currentLesson.name),
? currentLesson.name
: (isBreak ? 'Szünet' : currentLesson.name),
'lessonTheme': (isBeforeSchool || isBreak) ? null : currentLesson.theme, 'lessonTheme': (isBeforeSchool || isBreak) ? null : currentLesson.theme,
'roomName': (isBeforeSchool || isBreak) ? null : currentLesson.roomName, 'roomName': (isBeforeSchool || isBreak) ? null : currentLesson.roomName,
'teacherName': (isBeforeSchool || isBreak) ? null : currentLesson.teacher, 'teacherName': (isBeforeSchool || isBreak) ? null : currentLesson.teacher,
'startTime': startTimeForActivity.toUtc().toIso8601String(), 'startTime': startTimeForActivity.toUtc().toIso8601String(),
'endTime': endTimeForActivity.toUtc().toIso8601String(), 'endTime': endTimeForActivity.toUtc().toIso8601String(),
'lessonNumber': (isBeforeSchool || isBreak) 'lessonNumber': (isBeforeSchool || isBreak) ? null : currentLesson.lessonNumber,
? null
: currentLesson.lessonNumber,
'nextLessonName': isBeforeSchool ? null : nextLesson?.name, 'nextLessonName': isBeforeSchool ? null : nextLesson?.name,
'nextRoomName': isBeforeSchool ? null : nextLesson?.roomName, 'nextRoomName': isBeforeSchool ? null : nextLesson?.roomName,
'nextStartTime': nextStartTimeForActivity?.toUtc().toIso8601String(), 'nextStartTime': nextStartTimeForActivity?.toUtc().toIso8601String(),
'isSubstitution': currentLesson.substituteTeacher != null, 'isSubstitution': currentLesson.substituteTeacher != null,
'isCancelled': 'isCancelled': currentLesson.state.name?.toLowerCase().contains('elmarad') ?? false,
currentLesson.state.name?.toLowerCase().contains('elmarad') ?? false,
'substituteTeacher': currentLesson.substituteTeacher, 'substituteTeacher': currentLesson.substituteTeacher,
'currentTime': now.toUtc().toIso8601String(), 'currentTime': now.toUtc().toIso8601String(),
'mode': mode, 'mode': mode,

View File

@@ -1,15 +1,13 @@
import 'dart:io'; import 'dart:io';
import 'package:firka/app/app_state.dart'; import 'package:firka/main.dart';
import 'package:image/image.dart'; import 'package:image/image.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
Future<void> pickProfilePicture( Future<void> pickProfilePicture(
AppInitialization data, AppInitialization data, ImagePicker picker) async {
ImagePicker picker,
) async {
var imageFile = await picker.pickImage(source: ImageSource.gallery); var imageFile = await picker.pickImage(source: ImageSource.gallery);
if (imageFile == null) return; if (imageFile == null) return;
@@ -21,5 +19,5 @@ Future<void> pickProfilePicture(
await File(p.join(dataDir.path, "profile.webp")).writeAsBytes(bytes); await File(p.join(dataDir.path, "profile.webp")).writeAsBytes(bytes);
data.profilePicture = bytes; data.profilePicture = bytes;
data.profilePictureCubit?.notifyChanged(); data.profilePictureUpdateNotifier.update();
} }

File diff suppressed because it is too large Load Diff

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