1
0
forked from firka/firka

- 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.
This commit is contained in:
Horváth Gergely
2025-11-25 19:07:30 +01:00
committed by 4831c0
parent 8dbcc1e2a9
commit e583c77a7e
8 changed files with 460 additions and 64 deletions

View File

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

View File

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

View File

@@ -40,6 +40,12 @@
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
<string>fetch</string>
<string>processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>app.firka.timetable.refresh</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>

View File

@@ -8,7 +8,7 @@
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.firka.firka</string>
<string>group.app.firka.firkaa</string>
</array>
</dict>
</plist>

View File

@@ -6,7 +6,7 @@
<string>development</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.firka.firka</string>
<string>group.app.firka.firkaa</string>
</array>
</dict>
</plist>

View File

@@ -327,5 +327,32 @@ class LiveActivityBackendClient {
return false;
}
}
/// Update bellDelay preference for device
Future<bool> 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;
}
}
}

View File

@@ -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<bool> _getUserLiveActivityEnabled() async {
final studentId = _getCurrentStudentId();
static Future<bool> _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<void> _setUserLiveActivityEnabled(bool value) async {
final studentId = _getCurrentStudentId();
static Future<void> _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<bool> _getUserPrivacyEverDeclined() async {
final studentId = _getCurrentStudentId();
static Future<bool> _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<void> _setUserPrivacyEverDeclined(bool value) async {
final studentId = _getCurrentStudentId();
static Future<void> _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<void> syncGlobalSettingWithCurrentUser() async {
static Future<void> 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<bool> 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<void> 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<void> 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<Lesson> 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<bool> _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<Lesson> 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<Lesson>.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<Lesson>.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<bool> 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<void> handleEnabledChange(bool enabled, {bool isManual = false}) async {
static Future<void> 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<void> showConsentScreenIfNeeded() async {
static Future<void> 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<void> _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');
}
}
}

View File

@@ -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<String, SettingsItem> group(String key) {