From e583c77a7e4fe1a093c17a5e3f29cb6110b6fd2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20Gergely?= Date: Tue, 25 Nov 2025 19:07:30 +0100 Subject: [PATCH] - 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. --- firka/ios/Runner.xcodeproj/project.pbxproj | 47 ++- firka/ios/Runner/AppDelegate.swift | 91 ++++- firka/ios/Runner/Info.plist | 6 + firka/ios/Runner/Runner.entitlements | 2 +- .../ios/TimetableWidgetExtension.entitlements | 2 +- .../client/live_activity_backend_client.dart | 27 ++ firka/lib/helpers/live_activity_service.dart | 341 ++++++++++++++++-- firka/lib/helpers/settings.dart | 8 + 8 files changed, 460 insertions(+), 64 deletions(-) diff --git a/firka/ios/Runner.xcodeproj/project.pbxproj b/firka/ios/Runner.xcodeproj/project.pbxproj index 56e8cf08..4c4d9c34 100644 --- a/firka/ios/Runner.xcodeproj/project.pbxproj +++ b/firka/ios/Runner.xcodeproj/project.pbxproj @@ -552,7 +552,6 @@ 249021D3217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; buildSettings = { - VALID_ARCHS = arm64; ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; @@ -600,6 +599,7 @@ SUPPORTED_PLATFORMS = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; + VALID_ARCHS = arm64; }; name = Profile; }; @@ -607,14 +607,13 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - VALID_ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1021; - DEVELOPMENT_TEAM = R9PZGUCNJ3; + DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Firka; @@ -623,7 +622,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -633,6 +632,7 @@ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; + VALID_ARCHS = arm64; VERSIONING_SYSTEM = "apple-generic"; }; name = Profile; @@ -641,7 +641,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = 0EE927DD3F0F54BDE10EFE01 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { - VALID_ARCHS = arm64; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -653,6 +652,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + VALID_ARCHS = arm64; }; name = Debug; }; @@ -660,7 +660,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = B222D922BB8257D2341337A4 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { - VALID_ARCHS = arm64; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -670,6 +669,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + VALID_ARCHS = arm64; }; name = Release; }; @@ -677,7 +677,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = BDDC8A00836B054E202CC327 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { - VALID_ARCHS = arm64; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -687,13 +686,13 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + VALID_ARCHS = arm64; }; name = Profile; }; 4F30C77B2E8FBF9F008BB46C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - VALID_ARCHS = arm64; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; @@ -707,7 +706,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = R9PZGUCNJ3; + DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -725,7 +724,7 @@ MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.TimetableWidgetExtension; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.TimetableWidget; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; @@ -737,13 +736,13 @@ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = arm64; }; name = Debug; }; 4F30C77C2E8FBF9F008BB46C /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - VALID_ARCHS = arm64; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; @@ -757,7 +756,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = R9PZGUCNJ3; + DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -774,7 +773,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.TimetableWidgetExtension; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.TimetableWidget; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; @@ -784,13 +783,13 @@ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = arm64; }; name = Release; }; 4F30C77D2E8FBF9F008BB46C /* Profile */ = { isa = XCBuildConfiguration; buildSettings = { - VALID_ARCHS = arm64; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; @@ -804,7 +803,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = R9PZGUCNJ3; + DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -821,7 +820,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.TimetableWidgetExtension; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.TimetableWidget; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; @@ -831,13 +830,13 @@ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = arm64; }; name = Profile; }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - VALID_ARCHS = arm64; ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; @@ -890,13 +889,13 @@ ONLY_ACTIVE_ARCH = NO; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = arm64; }; name = Debug; }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - VALID_ARCHS = arm64; ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; @@ -946,6 +945,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; + VALID_ARCHS = arm64; }; name = Release; }; @@ -953,14 +953,13 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { - VALID_ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1021; - DEVELOPMENT_TEAM = R9PZGUCNJ3; + DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Firka; @@ -969,7 +968,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -989,14 +988,13 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - VALID_ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1021; - DEVELOPMENT_TEAM = R9PZGUCNJ3; + DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Firka; @@ -1005,7 +1003,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1015,6 +1013,7 @@ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; + VALID_ARCHS = arm64; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; diff --git a/firka/ios/Runner/AppDelegate.swift b/firka/ios/Runner/AppDelegate.swift index 40122f87..fe9612f9 100644 --- a/firka/ios/Runner/AppDelegate.swift +++ b/firka/ios/Runner/AppDelegate.swift @@ -2,6 +2,7 @@ import Flutter import UIKit import ActivityKit import UserNotifications +import BackgroundTasks @main @objc class AppDelegate: FlutterAppDelegate { @@ -9,15 +10,49 @@ import UserNotifications private var fallbackChannel: FlutterMethodChannel? private var deviceTokenString: String? private var notificationChannel: FlutterMethodChannel? + private var backgroundFetchChannel: FlutterMethodChannel? + + private let backgroundTaskIdentifier = "app.firka.timetable.refresh" override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) - + let controller = window?.rootViewController as! FlutterViewController - + + backgroundFetchChannel = FlutterMethodChannel(name: "firka.app/background_fetch", binaryMessenger: controller.binaryMessenger) + + backgroundFetchChannel?.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in + guard let self = self else { + result(FlutterError(code: "UNAVAILABLE", message: "AppDelegate not available", details: nil)) + return + } + + if #available(iOS 13.0, *) { + switch call.method { + case "scheduleBackgroundFetch": + self.scheduleBackgroundRefresh() + result(true) + case "cancelBackgroundFetch": + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: self.backgroundTaskIdentifier) + print("[AppDelegate] Background fetch cancelled from Flutter") + result(true) + default: + result(FlutterMethodNotImplemented) + } + } else { + result(FlutterError(code: "UNAVAILABLE", message: "Background fetch requires iOS 13+", details: nil)) + } + } + + if #available(iOS 13.0, *) { + BGTaskScheduler.shared.register(forTaskWithIdentifier: backgroundTaskIdentifier, using: nil) { task in + self.handleBackgroundRefresh(task: task as! BGAppRefreshTask) + } + } + notificationChannel = FlutterMethodChannel(name: "firka.app/notifications", binaryMessenger: controller.binaryMessenger) UNUserNotificationCenter.current().delegate = self @@ -118,7 +153,57 @@ import UserNotifications } } } - + completionHandler(.newData) } + + // MARK: - Background Refresh + + @available(iOS 13.0, *) + private func handleBackgroundRefresh(task: BGAppRefreshTask) { + scheduleBackgroundRefresh() + + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 1 + + let operation = BlockOperation { + DispatchQueue.main.async { + self.backgroundFetchChannel?.invokeMethod("performBackgroundFetch", arguments: nil) { result in + if let success = result as? Bool, success { + task.setTaskCompleted(success: true) + } else { + task.setTaskCompleted(success: false) + } + } + } + } + + task.expirationHandler = { + queue.cancelAllOperations() + task.setTaskCompleted(success: false) + } + + queue.addOperation(operation) + } + + @available(iOS 13.0, *) + func scheduleBackgroundRefresh() { + let request = BGAppRefreshTaskRequest(identifier: backgroundTaskIdentifier) + + // IMPORTANT: iOS may delay this based on system conditions and user behavior + // The default setting is 30 minutes + request.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) + + do { + try BGTaskScheduler.shared.submit(request) + print("[AppDelegate] Background refresh scheduled for ~30 minutes from now") + } catch { + print("[AppDelegate] Could not schedule background refresh: \(error)") + } + } + + override func applicationDidEnterBackground(_ application: UIApplication) { + // Background fetch will be scheduled from Flutter side when needed + // No automatic scheduling here to give Flutter full control + } } diff --git a/firka/ios/Runner/Info.plist b/firka/ios/Runner/Info.plist index 8d2c46c6..8f2548df 100644 --- a/firka/ios/Runner/Info.plist +++ b/firka/ios/Runner/Info.plist @@ -40,6 +40,12 @@ UIBackgroundModes remote-notification + fetch + processing + + BGTaskSchedulerPermittedIdentifiers + + app.firka.timetable.refresh UILaunchStoryboardName LaunchScreen diff --git a/firka/ios/Runner/Runner.entitlements b/firka/ios/Runner/Runner.entitlements index f0168976..f096a927 100644 --- a/firka/ios/Runner/Runner.entitlements +++ b/firka/ios/Runner/Runner.entitlements @@ -8,7 +8,7 @@ com.apple.security.application-groups - group.app.firka.firka + group.app.firka.firkaa diff --git a/firka/ios/TimetableWidgetExtension.entitlements b/firka/ios/TimetableWidgetExtension.entitlements index 2b50a608..b71b0c80 100644 --- a/firka/ios/TimetableWidgetExtension.entitlements +++ b/firka/ios/TimetableWidgetExtension.entitlements @@ -6,7 +6,7 @@ development com.apple.security.application-groups - group.app.firka.firka + group.app.firka.firkaa diff --git a/firka/lib/helpers/api/client/live_activity_backend_client.dart b/firka/lib/helpers/api/client/live_activity_backend_client.dart index d0ee2d80..c365f425 100644 --- a/firka/lib/helpers/api/client/live_activity_backend_client.dart +++ b/firka/lib/helpers/api/client/live_activity_backend_client.dart @@ -327,5 +327,32 @@ class LiveActivityBackendClient { return false; } } + + /// Update bellDelay preference for device + Future updateBellDelay({ + required String deviceToken, + required double bellDelay, + }) async { + try { + final response = await _dio.put( + '/live-activity/bell-delay', + data: { + 'deviceToken': deviceToken, + 'bellDelay': bellDelay, + }, + ); + + if (response.statusCode == 200) { + _logger.info('BellDelay updated to $bellDelay minutes successfully'); + return true; + } + + _logger.warning('Failed to update bellDelay: ${response.statusCode}'); + return false; + } catch (e) { + _logger.severe('Error updating bellDelay: $e'); + return false; + } + } } diff --git a/firka/lib/helpers/live_activity_service.dart b/firka/lib/helpers/live_activity_service.dart index 164775cb..73d61f9e 100644 --- a/firka/lib/helpers/live_activity_service.dart +++ b/firka/lib/helpers/live_activity_service.dart @@ -9,6 +9,7 @@ import 'package:firka/helpers/live_activity_manager.dart'; import 'package:firka/helpers/settings.dart'; import 'package:firka/ui/phone/screens/live_activity/live_activity_consent_screen.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:logging/logging.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -28,9 +29,18 @@ class LiveActivityService { static String? _cachedDeviceToken; static bool _isInitialized = false; + static Timer? _bellDelayDebounceTimer; + static double? _pendingBellDelay; + static double? _lastSentBellDelay; + static const Duration _bellDelayDebounceInterval = Duration(seconds: 5); + /// Get current user's studentId for user-specific settings - static String? _getCurrentStudentId() { + /// If client is provided, use it directly instead of initData.client + static String? _getCurrentStudentId({KretaClient? client}) { try { + if (client != null && client.model != null) { + return client.model.studentId; + } if (!initDone || initData.client == null || initData.client.model == null) { return null; } @@ -42,8 +52,8 @@ class LiveActivityService { } /// Get user-specific Live Activity enabled state from SharedPreferences - static Future _getUserLiveActivityEnabled() async { - final studentId = _getCurrentStudentId(); + static Future _getUserLiveActivityEnabled({KretaClient? client}) async { + final studentId = _getCurrentStudentId(client: client); if (studentId == null) return false; final prefs = await SharedPreferences.getInstance(); @@ -52,8 +62,8 @@ class LiveActivityService { } /// Set user-specific Live Activity enabled state to SharedPreferences - static Future _setUserLiveActivityEnabled(bool value) async { - final studentId = _getCurrentStudentId(); + static Future _setUserLiveActivityEnabled(bool value, {KretaClient? client}) async { + final studentId = _getCurrentStudentId(client: client); if (studentId == null) return; final prefs = await SharedPreferences.getInstance(); @@ -63,8 +73,8 @@ class LiveActivityService { } /// Get user-specific privacy declined state from SharedPreferences - static Future _getUserPrivacyEverDeclined() async { - final studentId = _getCurrentStudentId(); + static Future _getUserPrivacyEverDeclined({KretaClient? client}) async { + final studentId = _getCurrentStudentId(client: client); if (studentId == null) return false; final prefs = await SharedPreferences.getInstance(); @@ -73,8 +83,8 @@ class LiveActivityService { } /// Set user-specific privacy declined state to SharedPreferences - static Future _setUserPrivacyEverDeclined(bool value) async { - final studentId = _getCurrentStudentId(); + static Future _setUserPrivacyEverDeclined(bool value, {KretaClient? client}) async { + final studentId = _getCurrentStudentId(client: client); if (studentId == null) return; final prefs = await SharedPreferences.getInstance(); @@ -85,17 +95,17 @@ class LiveActivityService { /// Sync global setting with current user's setting /// This ensures the Settings UI shows the correct state for the current user - static Future syncGlobalSettingWithCurrentUser() async { + static Future syncGlobalSettingWithCurrentUser({KretaClient? client}) async { if (!Platform.isIOS) return; try { - final studentId = _getCurrentStudentId(); + final studentId = _getCurrentStudentId(client: client); if (studentId == null) { _logger.warning('Cannot sync global setting: no current user'); return; } - final userEnabled = await _getUserLiveActivityEnabled(); + final userEnabled = await _getUserLiveActivityEnabled(client: client); final globalSetting = initData.settings .group("settings") @@ -192,16 +202,206 @@ class LiveActivityService { await _saveDeviceToken(deviceToken); } + _setupBackgroundFetchChannel(); + _isInitialized = true; } catch (e, stackTrace) { _logger.severe('Failed to initialize LiveActivity: $e', e, stackTrace); } } - /// Check if LiveActivity is enabled in settings - static Future isEnabled([SettingsStore? settingsStore]) async { + /// Setup method channel for background fetch + static void _setupBackgroundFetchChannel() { + const platform = MethodChannel('firka.app/background_fetch'); + platform.setMethodCallHandler((call) async { + if (call.method == 'performBackgroundFetch') { + _logger.info('Background fetch triggered by iOS'); + final success = await _performBackgroundFetch(); + return success; + } + return false; + }); + } + + /// Schedule background fetch on iOS + static Future scheduleBackgroundFetch() async { + if (!Platform.isIOS) return; + try { - return await _getUserLiveActivityEnabled(); + const platform = MethodChannel('firka.app/background_fetch'); + await platform.invokeMethod('scheduleBackgroundFetch'); + _logger.info('Background fetch scheduled'); + } catch (e) { + _logger.warning('Failed to schedule background fetch: $e'); + } + } + + /// Cancel background fetch on iOS + static Future cancelBackgroundFetch() async { + if (!Platform.isIOS) return; + + try { + const platform = MethodChannel('firka.app/background_fetch'); + await platform.invokeMethod('cancelBackgroundFetch'); + _logger.info('Background fetch cancelled'); + } catch (e) { + _logger.warning('Failed to cancel background fetch: $e'); + } + } + + /// Check if there are any remaining lessons today + static bool _hasRemainingLessonsToday(List lessons) { + final now = DateTime.now(); + final todayLessons = lessons.where((lesson) { + final uid = lesson.uid?.toLowerCase() ?? ''; + return lesson.date == now.toIso8601String().split('T').first && + lesson.end.isAfter(now) && + (uid.contains('orarendiora') || + uid.contains('tanitasiora') || + uid.contains('uresora')); + }).toList(); + + return todayLessons.isNotEmpty; + } + + /// Perform background fetch - fetch fresh timetable from KRÉTA API and send to backend + /// This is called by iOS BGTaskScheduler when the app is in background + static Future _performBackgroundFetch() async { + if (!Platform.isIOS || !_isInitialized || !initDone) { + _logger.warning('Background fetch skipped: not initialized or initDone=false'); + return false; + } + + try { + final client = initData.client; + if (client == null || client.model == null) { + _logger.warning('Background fetch skipped: no client available'); + return false; + } + + final enabled = await isEnabled(initData.settings, client); + if (!enabled) { + _logger.info('Background fetch skipped: LiveActivity disabled'); + return false; + } + + _logger.info('Background fetch: fetching fresh timetable from KRÉTA API'); + + final now = DateTime.now(); + final startOfWeek = now.subtract(Duration(days: now.weekday - 1)); + final endOfWeek = startOfWeek.add(const Duration(days: 6)); + + List allLessons = []; + + try { + _logger.info('Background fetch: attempting to fetch fresh data from KRÉTA API'); + final timetableResponse = await client.getTimeTable(startOfWeek, endOfWeek, forceCache: false); + + if (timetableResponse.response != null) { + allLessons = List.from(timetableResponse.response!); + _logger.info('Background fetch: successfully fetched ${allLessons.length} lessons from KRÉTA API'); + } else { + throw Exception('KRÉTA API returned null response'); + } + } catch (e) { + _logger.warning('Background fetch: KRÉTA API failed ($e), falling back to cache'); + try { + final cachedResponse = await client.getTimeTable(startOfWeek, endOfWeek, forceCache: true); + if (cachedResponse.response != null) { + allLessons = List.from(cachedResponse.response!); + _logger.info('Background fetch: successfully loaded ${allLessons.length} lessons from cache'); + } else { + _logger.severe('Background fetch: both API and cache failed'); + return false; + } + } catch (cacheError) { + _logger.severe('Background fetch: cache fallback also failed: $cacheError'); + return false; + } + } + + final nextMonday = endOfWeek.add(const Duration(days: 1)); + final nextMondayEnd = nextMonday.add(const Duration(days: 1)); + + try { + final nextMondayResponse = await client.getTimeTable(nextMonday, nextMondayEnd, forceCache: false); + if (nextMondayResponse.response != null && nextMondayResponse.response!.isNotEmpty) { + final mondayLessons = nextMondayResponse.response!; + mondayLessons.sort((a, b) => a.start.compareTo(b.start)); + final firstLesson = mondayLessons.first; + + final markedLesson = Lesson( + uid: '${firstLesson.uid}__FOR_NOTIFICATION_ONLY', + date: firstLesson.date, + start: firstLesson.start, + end: firstLesson.end, + name: firstLesson.name, + lessonNumber: firstLesson.lessonNumber, + teacher: firstLesson.teacher, + theme: firstLesson.theme, + roomName: firstLesson.roomName, + substituteTeacher: firstLesson.substituteTeacher, + type: firstLesson.type, + state: firstLesson.state, + canStudentEditHomework: firstLesson.canStudentEditHomework, + isHomeworkComplete: firstLesson.isHomeworkComplete, + attachments: firstLesson.attachments, + isDigitalLesson: firstLesson.isDigitalLesson, + digitalSupportDeviceTypeList: firstLesson.digitalSupportDeviceTypeList, + createdAt: firstLesson.createdAt ?? firstLesson.lastModifiedAt ?? DateTime.now(), + lastModifiedAt: firstLesson.lastModifiedAt, + ); + + allLessons.add(markedLesson); + _logger.info('Background fetch: added next Monday first lesson for notification'); + } + } catch (e) { + _logger.warning('Background fetch: could not fetch next Monday lesson: $e'); + } + + if (allLessons.isEmpty) { + _logger.info('Background fetch: no lessons to send'); + return true; + } + + final deviceToken = await _getOrWaitDeviceToken(); + if (deviceToken == null) { + _logger.warning('Background fetch: no device token available'); + return false; + } + + _logger.info('Background fetch: sending ${allLessons.length} lessons to backend'); + final success = await _backendClient.updateTimetable( + deviceToken: deviceToken, + timetable: allLessons, + ); + + if (success) { + await _saveLastUpdate(); + _logger.info('Background fetch: successfully sent timetable to backend'); + + if (!_hasRemainingLessonsToday(allLessons)) { + _logger.info('Background fetch: no remaining lessons today, cancelling future background fetches until app reopens'); + await cancelBackgroundFetch(); + } else { + _logger.info('Background fetch: remaining lessons today, will continue background fetches'); + } + + return true; + } else { + _logger.warning('Background fetch: failed to send timetable to backend'); + return false; + } + } catch (e, stackTrace) { + _logger.severe('Background fetch: unexpected error: $e', e, stackTrace); + return false; + } + } + + /// Check if LiveActivity is enabled in settings + static Future isEnabled([SettingsStore? settingsStore, KretaClient? client]) async { + try { + return await _getUserLiveActivityEnabled(client: client); } catch (e) { _logger.warning('Error reading LiveActivity setting: $e'); return false; @@ -210,11 +410,12 @@ class LiveActivityService { /// Handle LiveActivity enabled state change /// Called from settings toggle callback - static Future handleEnabledChange(bool enabled, {bool isManual = false}) async { + static Future handleEnabledChange(bool enabled, {bool isManual = false, KretaClient? client}) async { if (!Platform.isIOS) return; try { - final studentId = _getCurrentStudentId(); + final effectiveClient = client ?? initData.client; + final studentId = _getCurrentStudentId(client: effectiveClient); if (studentId == null) { _logger.warning('Cannot change LiveActivity state: no current user'); return; @@ -223,9 +424,9 @@ class LiveActivityService { if (!enabled) { await onUserLogout(); - await _setUserLiveActivityEnabled(false); + await _setUserLiveActivityEnabled(false, client: effectiveClient); - await syncGlobalSettingWithCurrentUser(); + await syncGlobalSettingWithCurrentUser(client: effectiveClient); _logger.info('LiveActivity disabled and user data cleared.'); } else { @@ -235,25 +436,25 @@ class LiveActivityService { if (accepted == true) { _logger.info('User accepted privacy policy'); - await _setUserLiveActivityEnabled(true); + await _setUserLiveActivityEnabled(true, client: effectiveClient); - await syncGlobalSettingWithCurrentUser(); + await syncGlobalSettingWithCurrentUser(client: effectiveClient); - final studentResp = await initData.client.getStudent(); + final studentResp = await effectiveClient.getStudent(); final studentName = studentResp.response?.name ?? initData.tokens.first.studentId ?? "Student"; await onUserLogin( - client: initData.client, + client: effectiveClient, studentName: studentName, settingsStore: initData.settings, ); } else { _logger.info('User declined privacy policy or swiped back'); - await _setUserLiveActivityEnabled(false); - await _setUserPrivacyEverDeclined(true); + await _setUserLiveActivityEnabled(false, client: effectiveClient); + await _setUserPrivacyEverDeclined(true, client: effectiveClient); - await syncGlobalSettingWithCurrentUser(); + await syncGlobalSettingWithCurrentUser(client: effectiveClient); } } } catch (e) { @@ -263,24 +464,25 @@ class LiveActivityService { /// Show privacy consent screen automatically on first use or user switch /// Only shows if user hasn't declined before - static Future showConsentScreenIfNeeded() async { + static Future showConsentScreenIfNeeded({KretaClient? client}) async { if (!Platform.isIOS) return; try { - final studentId = _getCurrentStudentId(); + final effectiveClient = client ?? initData.client; + final studentId = _getCurrentStudentId(client: effectiveClient); if (studentId == null) { _logger.warning('Cannot check consent screen: no current user'); return; } - await syncGlobalSettingWithCurrentUser(); + await syncGlobalSettingWithCurrentUser(client: effectiveClient); - final enabled = await _getUserLiveActivityEnabled(); - final everDeclined = await _getUserPrivacyEverDeclined(); + final enabled = await _getUserLiveActivityEnabled(client: effectiveClient); + final everDeclined = await _getUserPrivacyEverDeclined(client: effectiveClient); if (!enabled && !everDeclined) { _logger.info('First use or new user - showing privacy consent automatically'); - await handleEnabledChange(true, isManual: false); + await handleEnabledChange(true, isManual: false, client: effectiveClient); } else { _logger.info('User already has LiveActivity setting: enabled=$enabled, declined=$everDeclined'); } @@ -375,7 +577,7 @@ class LiveActivityService { return; } - final enabled = await isEnabled(settingsStore); + final enabled = await isEnabled(settingsStore, client); _logger.info('onUserLogin: LiveActivity enabled=$enabled'); if (!enabled) { @@ -474,6 +676,9 @@ class LiveActivityService { studentName: studentName, settingsStore: settingsStore, ); + + await scheduleBackgroundFetch(); + _logger.info('LiveActivity registration completed for $studentName'); } else { _logger.warning('Failed to register device with backend'); @@ -492,7 +697,7 @@ class LiveActivityService { if (!Platform.isIOS || !_isInitialized) return; try { - final enabled = await isEnabled(settingsStore); + final enabled = await isEnabled(settingsStore, client); if (!enabled) { _logger.info('LiveActivity is disabled, ending any running activities'); await LiveActivityManager.endAllActivities(); @@ -524,6 +729,8 @@ class LiveActivityService { settingsStore: settingsStore ); + await scheduleBackgroundFetch(); + } catch (e) { _logger.severe('Error handling onAppOpened for LiveActivity: $e'); } @@ -570,7 +777,7 @@ class LiveActivityService { }) async { if (!Platform.isIOS || !_isInitialized) return; - final enabled = await isEnabled(settingsStore); + final enabled = await isEnabled(settingsStore, client); if (!enabled) { return; } @@ -864,4 +1071,68 @@ class LiveActivityService { return false; } } + + /// Handle bellDelay change with debounce + /// Waits 5 seconds after the last change before sending update to backend + /// If value changes during the wait, reschedules the update with the new value + static void onBellDelayChanged(double newValue) { + if (!Platform.isIOS || !_isInitialized) return; + + _logger.info('BellDelay changed to $newValue minutes, scheduling debounced update'); + + _bellDelayDebounceTimer?.cancel(); + + _pendingBellDelay = newValue; + + _bellDelayDebounceTimer = Timer(_bellDelayDebounceInterval, () async { + await _sendBellDelayUpdate(); + }); + } + + /// Internal function to send bellDelay update to backend + static Future _sendBellDelayUpdate() async { + if (_pendingBellDelay == null) return; + + final bellDelayToSend = _pendingBellDelay!; + + if (_lastSentBellDelay == bellDelayToSend) { + _logger.info('BellDelay $bellDelayToSend already sent to backend, skipping'); + _pendingBellDelay = null; + return; + } + + try { + final deviceToken = await _getOrWaitDeviceToken(); + if (deviceToken == null) { + _logger.warning('No device token available to update bellDelay'); + return; + } + + _logger.info('Sending bellDelay update to backend: $bellDelayToSend minutes'); + + final success = await _backendClient.updateBellDelay( + deviceToken: deviceToken, + bellDelay: bellDelayToSend, + ); + + if (success) { + _lastSentBellDelay = bellDelayToSend; + _logger.info('BellDelay updated successfully in backend'); + + if (_pendingBellDelay != bellDelayToSend) { + _logger.info('BellDelay changed to $_pendingBellDelay during update, scheduling another update'); + _bellDelayDebounceTimer?.cancel(); + _bellDelayDebounceTimer = Timer(_bellDelayDebounceInterval, () async { + await _sendBellDelayUpdate(); + }); + } else { + _pendingBellDelay = null; + } + } else { + _logger.warning('Failed to update bellDelay in backend'); + } + } catch (e) { + _logger.severe('Error updating bellDelay: $e'); + } + } } \ No newline at end of file diff --git a/firka/lib/helpers/settings.dart b/firka/lib/helpers/settings.dart index c1b7b97c..23e69d49 100644 --- a/firka/lib/helpers/settings.dart +++ b/firka/lib/helpers/settings.dart @@ -480,6 +480,14 @@ class SettingsStore { "xmas2": l10n.ic_xmas2, "xmas3": l10n.ic_xmas3 }; + + if (Platform.isIOS) { + final bellDelaySetting = group("settings") + .subGroup("application")["bell_delay"] as SettingsDouble; + bellDelaySetting.postUpdate = () async { + LiveActivityService.onBellDelayChanged(bellDelaySetting.value); + }; + } } LinkedHashMap group(String key) {