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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user