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) {