Add morning notification settings and debounce handling for Live Activities

This commit is contained in:
Horváth Gergely
2025-12-12 23:59:32 +01:00
parent 4fd3e2a09b
commit b8058cd4cb
10 changed files with 687 additions and 290 deletions

View File

@@ -141,12 +141,13 @@ class LiveActivityMethodChannelManager: NSObject {
let attributes = try JSONDecoder().decode([String: String].self, from: attributesData)
let contentState = try JSONDecoder().decode(TimetableActivityAttributes.ContentState.self, from: contentStateData)
if let existingActivity = Activity<TimetableActivityAttributes>.activities.first {
await existingActivity.update(ActivityContent<TimetableActivityAttributes.ContentState>(state: contentState, staleDate: nil))
result(existingActivity.id)
return
let existingActivities = Activity<TimetableActivityAttributes>.activities
for activity in existingActivities {
await activity.end(nil, dismissalPolicy: .immediate)
}
try await Task.sleep(nanoseconds: 100_000_000)
guard let studentName = attributes["studentName"],
let schoolName = attributes["schoolName"] else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing student or school name", details: nil))
@@ -246,4 +247,4 @@ class LiveActivityMethodChannelManager: NSObject {
result(true)
}
}
}
}

View File

@@ -12,7 +12,7 @@ struct TimetableActivityAttributes: ActivityAttributes {
var endTime: Date
var lessonNumber: Int?
var mode: String? // "lesson" | "break" | "seasonalBreak" | "xmas" | "newYear"
var mode: String? // "lesson" | "break" | "seasonalBreak" | "xmas" | "newYearEve" | "newYearDay"
var message: String?
var season: String?
@@ -28,6 +28,8 @@ struct TimetableActivityAttributes: ActivityAttributes {
var currentTime: Date
var tokenExpirationWarning: String?
enum CodingKeys: String, CodingKey {
case isBreak
@@ -63,49 +65,53 @@ struct TimetableActivityAttributes: ActivityAttributes {
case isCancelled
case substituteTeacher
case currentTime
case tokenExpirationWarning
}
init(isBreak: Bool, lessonName: String, lessonTheme: String?, roomName: String?, teacherName: String?, startTime: Date, endTime: Date, lessonNumber: Int?, mode: String?, message: String?, season: String?, nextLessonName: String?, nextRoomName: String?, nextStartTime: Date?, isSubstitution: Bool?, isCancelled: Bool?, substituteTeacher: String?, currentTime: Date) {
init(isBreak: Bool, lessonName: String, lessonTheme: String?, roomName: String?, teacherName: String?, startTime: Date, endTime: Date, lessonNumber: Int?, mode: String?, message: String?, season: String?, nextLessonName: String?, nextRoomName: String?, nextStartTime: Date?, isSubstitution: Bool?, isCancelled: Bool?, substituteTeacher: String?, currentTime: Date, tokenExpirationWarning: String? = nil) {
self.isBreak = isBreak
self.lessonName = lessonName
self.lessonTheme = lessonTheme
self.roomName = roomName
self.teacherName = teacherName
self.startTime = startTime
self.endTime = endTime
self.lessonNumber = lessonNumber
self.mode = mode
self.message = message
self.season = season
self.nextLessonName = nextLessonName
self.nextRoomName = nextRoomName
self.nextStartTime = nextStartTime
self.isSubstitution = isSubstitution
self.isCancelled = isCancelled
self.substituteTeacher = substituteTeacher
self.currentTime = currentTime
self.tokenExpirationWarning = tokenExpirationWarning
}
@@ -185,12 +191,14 @@ struct TimetableActivityAttributes: ActivityAttributes {
isSubstitution = try container.decodeIfPresent(Bool.self, forKey: .isSubstitution)
isCancelled = try container.decodeIfPresent(Bool.self, forKey: .isCancelled)
substituteTeacher = try container.decodeIfPresent(String.self, forKey: .substituteTeacher)
tokenExpirationWarning = try container.decodeIfPresent(String.self, forKey: .tokenExpirationWarning)
let currentTimeStr = try container.decode(String.self, forKey: .currentTime)
@@ -259,12 +267,14 @@ struct TimetableActivityAttributes: ActivityAttributes {
try container.encodeIfPresent(isSubstitution, forKey: .isSubstitution)
try container.encodeIfPresent(isCancelled, forKey: .isCancelled)
try container.encodeIfPresent(substituteTeacher, forKey: .substituteTeacher)
try container.encodeIfPresent(tokenExpirationWarning, forKey: .tokenExpirationWarning)
try container.encode(isoFormatter.string(from: currentTime), forKey: .currentTime)
@@ -530,15 +540,21 @@ struct TimetableActivityAttributes: ActivityAttributes {
}
if let season = season {
json["season"] = season
}
if let tokenExpirationWarning = tokenExpirationWarning {
json["tokenExpirationWarning"] = tokenExpirationWarning
}
return json
}
@@ -619,17 +635,19 @@ struct TimetableActivityAttributes: ActivityAttributes {
nextStartTime: nextStartTime,
isSubstitution: json["isSubstitution"] as? Bool,
isCancelled: json["isCancelled"] as? Bool,
substituteTeacher: json["substituteTeacher"] as? String,
currentTime: currentTime
currentTime: currentTime,
tokenExpirationWarning: json["tokenExpirationWarning"] as? String
)
}
}

View File

@@ -12,7 +12,7 @@ struct TimetableActivityAttributes: ActivityAttributes {
var endTime: Date
var lessonNumber: Int?
var mode: String? // "lesson" | "break" | "seasonalBreak" | "xmas" | "newYear"
var mode: String? // "lesson" | "break" | "seasonalBreak" | "xmas" | "newYearEve" | "newYearDay"
var message: String?
var season: String?
@@ -27,6 +27,7 @@ struct TimetableActivityAttributes: ActivityAttributes {
var currentTime: Date
var labels: Labels?
var tokenExpirationWarning: String?
struct Labels: Codable, Hashable {
var title: String?
@@ -39,6 +40,9 @@ struct TimetableActivityAttributes: ActivityAttributes {
var nextLabel: String?
var firstLessonLabel: String?
var startTimeLabel: String?
var lessonNumberLabel: String?
var nextRoomLabel: String?
var remainingLabel: String?
}
enum CodingKeys: String, CodingKey {
@@ -61,9 +65,10 @@ struct TimetableActivityAttributes: ActivityAttributes {
case substituteTeacher
case currentTime
case labels
case tokenExpirationWarning
}
init(isBreak: Bool, lessonName: String, lessonTheme: String?, roomName: String?, teacherName: String?, startTime: Date, endTime: Date, lessonNumber: Int?, mode: String?, message: String?, season: String?, nextLessonName: String?, nextRoomName: String?, nextStartTime: Date?, isSubstitution: Bool, isCancelled: Bool, substituteTeacher: String?, currentTime: Date, labels: Labels? = nil) {
init(isBreak: Bool, lessonName: String, lessonTheme: String?, roomName: String?, teacherName: String?, startTime: Date, endTime: Date, lessonNumber: Int?, mode: String?, message: String?, season: String?, nextLessonName: String?, nextRoomName: String?, nextStartTime: Date?, isSubstitution: Bool, isCancelled: Bool, substituteTeacher: String?, currentTime: Date, labels: Labels? = nil, tokenExpirationWarning: String? = nil) {
self.isBreak = isBreak
self.lessonName = lessonName
self.lessonTheme = lessonTheme
@@ -83,6 +88,7 @@ struct TimetableActivityAttributes: ActivityAttributes {
self.substituteTeacher = substituteTeacher
self.currentTime = currentTime
self.labels = labels
self.tokenExpirationWarning = tokenExpirationWarning
}
init(from decoder: Decoder) throws {
@@ -126,6 +132,7 @@ struct TimetableActivityAttributes: ActivityAttributes {
isCancelled = try container.decode(Bool.self, forKey: .isCancelled)
substituteTeacher = try container.decodeIfPresent(String.self, forKey: .substituteTeacher)
labels = try container.decodeIfPresent(Labels.self, forKey: .labels)
tokenExpirationWarning = try container.decodeIfPresent(String.self, forKey: .tokenExpirationWarning)
let currentTimeStr = try container.decode(String.self, forKey: .currentTime)
guard let currentTimeDate = isoFormatter.date(from: currentTimeStr) else {
@@ -164,6 +171,7 @@ struct TimetableActivityAttributes: ActivityAttributes {
try container.encode(isCancelled, forKey: .isCancelled)
try container.encodeIfPresent(substituteTeacher, forKey: .substituteTeacher)
try container.encodeIfPresent(labels, forKey: .labels)
try container.encodeIfPresent(tokenExpirationWarning, forKey: .tokenExpirationWarning)
try container.encode(isoFormatter.string(from: currentTime), forKey: .currentTime)
}
@@ -293,7 +301,10 @@ extension TimetableActivityAttributes.ContentState {
if let season = season {
json["season"] = season
}
if let tokenExpirationWarning = tokenExpirationWarning {
json["tokenExpirationWarning"] = tokenExpirationWarning
}
return json
}
@@ -334,7 +345,10 @@ extension TimetableActivityAttributes.ContentState {
themeLabel: labelsDict["themeLabel"] as? String,
nextLabel: labelsDict["nextLabel"] as? String,
firstLessonLabel: labelsDict["firstLessonLabel"] as? String,
startTimeLabel: labelsDict["startTimeLabel"] as? String
startTimeLabel: labelsDict["startTimeLabel"] as? String,
lessonNumberLabel: labelsDict["lessonNumberLabel"] as? String,
nextRoomLabel: labelsDict["nextRoomLabel"] as? String,
remainingLabel: labelsDict["remainingLabel"] as? String
)
} else {
labels = nil
@@ -359,7 +373,8 @@ extension TimetableActivityAttributes.ContentState {
isCancelled: isCancelled,
substituteTeacher: json["substituteTeacher"] as? String,
currentTime: currentTime,
labels: labels
labels: labels,
tokenExpirationWarning: json["tokenExpirationWarning"] as? String
)
}
}

View File

@@ -12,41 +12,10 @@ struct TimetableLiveActivity: Widget {
.activitySystemActionForegroundColor(Color.white)
} dynamicIsland: { context in
let mode = context.state.mode ?? (context.state.isBreak ? "break" : "lesson")
let timeFormatter = DateFormatter()
timeFormatter.dateFormat = "HH:mm"
if mode == "end" {
return DynamicIsland {
// Expanded UI for 'end' state
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "checkmark.circle.fill").foregroundColor(.green)
}
DynamicIslandExpandedRegion(.trailing) {
EmptyView()
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.lessonName)
.lineLimit(1)
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
}
DynamicIslandExpandedRegion(.bottom) {
Text("A mai órarended véget ért.")
.font(.system(size: 12))
.foregroundColor(.gray)
}
} compactLeading: {
Image(systemName: "checkmark.circle.fill").foregroundColor(.green)
} compactTrailing: {
Text("Vége")
.font(.system(size: 12, weight: .semibold, design: .rounded))
.foregroundColor(.green)
} minimal: {
Image(systemName: "checkmark.circle.fill").foregroundColor(.green)
}
} else {
let timeFormatter = DateFormatter()
timeFormatter.dateFormat = "HH:mm"
return DynamicIsland {
return DynamicIsland {
// Expanded UI
DynamicIslandExpandedRegion(.leading) {
EmptyView()
@@ -132,7 +101,7 @@ struct TimetableLiveActivity: Widget {
Image(systemName: SeasonalIconHelper.iconName(for: mode, season: season))
.font(.system(size: 18))
.foregroundColor(SeasonalIconHelper.iconColor(for: mode))
Text(SeasonalIconHelper.holidayTitle(for: season))
Text(context.state.labels?.title ?? SeasonalIconHelper.holidayTitle(for: season))
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
.lineLimit(1)
@@ -204,9 +173,10 @@ struct TimetableLiveActivity: Widget {
}
DynamicIslandExpandedRegion(.bottom) {
HStack {
Spacer()
if mode == "xmas" || mode == "newYearDay" {
VStack(spacing: 4) {
HStack {
Spacer()
if mode == "xmas" || mode == "newYearDay" {
EmptyView()
} else if mode == "beforeSchool" {
if context.state.endTime > context.state.currentTime {
@@ -240,7 +210,24 @@ struct TimetableLiveActivity: Widget {
.multilineTextAlignment(.center)
.monospacedDigit()
}
Spacer()
Spacer()
}
// Token expiration warning (only for specific modes)
let mode3 = context.state.mode ?? (context.state.isBreak ? "break" : "lesson")
let showWarningModes = ["newYearEve", "lesson", "break", "seasonalBreak"]
if let warning = context.state.tokenExpirationWarning, !warning.isEmpty, showWarningModes.contains(mode3) {
HStack(spacing: 3) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 8))
.foregroundColor(.orange)
Text(warning)
.font(.system(size: 8, weight: .semibold))
.foregroundColor(.orange)
.lineLimit(1)
.minimumScaleFactor(0.6)
}
}
}
}
} compactLeading: {
@@ -294,7 +281,6 @@ struct TimetableLiveActivity: Widget {
}
}
}
}
// MARK: - Lock Screen View
@available(iOS 16.2, *)
@@ -303,31 +289,12 @@ struct TimetableLiveActivityView: View {
var body: some View {
let mode = context.state.mode ?? (context.state.isBreak ? "break" : "lesson")
if mode == "end" {
VStack(spacing: 8) {
HStack {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 24))
.foregroundColor(.green)
Text(context.state.lessonName)
.font(.system(size: 20, weight: .bold))
.foregroundColor(.white)
Spacer()
}
Text("A mai órarended véget ért.")
.font(.system(size: 14))
.foregroundColor(.gray)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(16)
} else {
VStack(spacing: 12) {
let season = context.state.season ?? ""
let screenTimeFormatter = DateFormatter()
let _ = { screenTimeFormatter.dateFormat = "HH:mm" }()
let beforeSchoolTime = mode == "beforeSchool" ? {
let beforeSchoolTime: String? = mode == "beforeSchool" ? {
let adjustedDate = context.state.endTime
return screenTimeFormatter.string(from: adjustedDate)
}() : nil
@@ -357,7 +324,7 @@ struct TimetableLiveActivityView: View {
.foregroundColor(.white)
.lineLimit(2)
} else {
Text(SeasonalIconHelper.holidayTitle(for: context.state.season))
Text(context.state.labels?.title ?? SeasonalIconHelper.holidayTitle(for: context.state.season))
.font(.system(size: 20, weight: .bold))
.foregroundColor(.white)
.lineLimit(1)
@@ -373,7 +340,7 @@ struct TimetableLiveActivityView: View {
}
} else {
if let lessonNumber = context.state.lessonNumber {
Text("\(lessonNumber). óra")
Text("\(lessonNumber)\(context.state.labels?.lessonNumberLabel ?? ". óra")")
.font(.system(size: 12))
.foregroundColor(.gray)
}
@@ -501,7 +468,7 @@ struct TimetableLiveActivityView: View {
Image(systemName: "door.left.hand.closed")
.font(.system(size: 12))
.foregroundColor(.gray)
Text("Következő terem: \(nextRoomName)")
Text("\(context.state.labels?.nextRoomLabel ?? "Következő terem:") \(nextRoomName)")
.font(.system(size: 12))
.foregroundColor(.gray)
}
@@ -516,7 +483,7 @@ struct TimetableLiveActivityView: View {
if mode3 == "xmas" || mode3 == "newYearDay" {
EmptyView()
} else if mode3 == "beforeSchool" {
Text("Első óra kezdése")
Text(context.state.labels?.timerLabel ?? "Első óra kezdése")
.font(.system(size: 10))
.foregroundColor(.gray)
@@ -534,7 +501,7 @@ struct TimetableLiveActivityView: View {
.monospacedDigit()
}
} else if mode3 == "seasonalBreak" {
Text("Szünetből hátralévő idő")
Text(context.state.labels?.remainingLabel ?? "Szünetből hátralévő idő")
.font(.system(size: 10))
.foregroundColor(.gray)
Text(context.state.seasonalDisplayValue)
@@ -551,7 +518,7 @@ struct TimetableLiveActivityView: View {
} else {
let labelText: String = {
if mode3 == "newYearEve" {
return "Új év"
return context.state.labels?.timerLabel ?? "Új év"
} else if mode3 == "beforeSchool" {
return context.state.labels?.timerLabel ?? "Első óra kezdése"
} else if context.state.isBreak {
@@ -573,8 +540,23 @@ struct TimetableLiveActivityView: View {
}
Spacer()
}
// Token expiration warning (only for specific modes)
let showWarningModes = ["newYearEve", "lesson", "break", "seasonalBreak"]
if let warning = context.state.tokenExpirationWarning, !warning.isEmpty, showWarningModes.contains(mode) {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 10))
.foregroundColor(.orange)
Text(warning)
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.orange)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.padding(.top, 2)
}
}
.padding(16)
}
}
}
}

View File

@@ -366,5 +366,61 @@ class LiveActivityBackendClient {
return false;
}
}
/// Update morning notification settings for device
Future<bool> updateMorningNotificationSettings({
required String deviceToken,
int? morningNotificationTime,
bool? morningNotificationEnabled,
}) async {
try {
final response = await _dio.put(
'/live-activity/morning-notification',
data: {
'deviceToken': deviceToken,
if (morningNotificationTime != null) 'morningNotificationTime': morningNotificationTime,
if (morningNotificationEnabled != null) 'morningNotificationEnabled': morningNotificationEnabled,
},
);
if (response.statusCode == 200) {
_logger.info('Morning notification settings updated successfully: enabled=$morningNotificationEnabled, time=$morningNotificationTime');
return true;
}
_logger.warning('Failed to update morning notification settings: ${response.statusCode}');
return false;
} catch (e) {
_logger.severe('Error updating morning notification settings: $e');
return false;
}
}
/// Toggle Live Activity feature (enable/disable)
Future<bool> toggleLiveActivity({
required String deviceToken,
required bool liveActivityEnabled,
}) async {
try {
final response = await _dio.put(
'/live-activity/toggle',
data: {
'deviceToken': deviceToken,
'liveActivityEnabled': liveActivityEnabled,
},
);
if (response.statusCode == 200) {
_logger.info('Live Activity ${liveActivityEnabled ? "enabled" : "disabled"} successfully');
return true;
}
_logger.warning('Failed to toggle Live Activity: ${response.statusCode}');
return false;
} catch (e) {
_logger.severe('Error toggling Live Activity: $e');
return false;
}
}
}

View File

@@ -101,13 +101,9 @@ class LiveActivityManager {
try {
await _syncActivityState();
if (_isActivityActive) {
_logger.info('Activity already exists, updating instead.');
return updateActivity(
currentLesson: currentLesson,
nextLesson: nextLesson,
isBreak: isBreak,
mode: mode,
);
_logger.info('Activity already exists, ending it to create new one with fresh token');
await endAllActivities();
await Future.delayed(const Duration(milliseconds: 500));
}
final contentState = _createContentState(

View File

@@ -34,6 +34,13 @@ class LiveActivityService {
static double? _lastSentBellDelay;
static const Duration _bellDelayDebounceInterval = Duration(seconds: 3);
static Timer? _morningNotificationDebounceTimer;
static double? _pendingMorningNotificationTime;
static bool? _pendingMorningNotificationEnabled;
static double? _lastSentMorningNotificationTime;
static bool? _lastSentMorningNotificationEnabled;
static const Duration _morningNotificationDebounceInterval = Duration(seconds: 3);
/// Get current bellDelay value from settings
static double? _getCurrentBellDelay() {
try {
@@ -124,7 +131,7 @@ class LiveActivityService {
final globalSetting = initData.settings
.group("settings")
.subGroup("application")["live_activity_enabled"] as SettingsBoolean;
.subGroup("notifications")["live_activity_enabled"] as SettingsBoolean;
if (globalSetting.value != userEnabled) {
globalSetting.value = userEnabled;
@@ -342,43 +349,67 @@ class LiveActivityService {
}
}
final nextMonday = endOfWeek.add(const Duration(days: 1));
final nextMondayEnd = nextMonday.add(const Duration(days: 1));
bool foundFirstSchoolDay = false;
for (int dayOffset = 1; dayOffset <= 5; dayOffset++) {
final candidateDay = endOfWeek.add(Duration(days: dayOffset));
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');
if (candidateDay.weekday == DateTime.saturday || candidateDay.weekday == DateTime.sunday) {
continue;
}
} catch (e) {
_logger.warning('Background fetch: could not fetch next Monday lesson: $e');
try {
final candidateDayEnd = candidateDay.add(const Duration(days: 1));
final response = await client.getTimeTable(candidateDay, candidateDayEnd, forceCache: false);
if (response.response != null && response.response!.isNotEmpty) {
final schoolLessons = response.response!.where((lesson) {
final uid = lesson.uid.toLowerCase();
return uid.contains('orarendiora') || uid.contains('tanitasiora') || uid.contains('uresora');
}).toList();
if (schoolLessons.isNotEmpty) {
schoolLessons.sort((a, b) => a.start.compareTo(b.start));
final firstLesson = schoolLessons.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);
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
final dayName = dayNames[candidateDay.weekday - 1];
_logger.info('Background fetch: added first lesson from next week $dayName (${firstLesson.name}) marked for notification scheduling only');
foundFirstSchoolDay = true;
break;
}
}
} catch (e) {
_logger.warning('Background fetch: could not fetch lessons for day offset $dayOffset: $e');
}
}
if (!foundFirstSchoolDay) {
_logger.info('Background fetch: no school lessons found in next week for push notification scheduling');
}
if (allLessons.isEmpty) {
@@ -445,13 +476,28 @@ class LiveActivityService {
}
if (!enabled) {
await onUserLogout();
final deviceToken = _cachedDeviceToken ?? await LiveActivityManager.getDeviceToken();
if (deviceToken != null) {
_logger.info('Notifying backend that Live Activity is disabled');
await _backendClient.toggleLiveActivity(
deviceToken: deviceToken,
liveActivityEnabled: false,
);
}
_logger.info('Ending all LiveActivities');
await LiveActivityManager.endAllActivities();
_logger.info('Clearing cache');
await _clearCache();
_logger.info('Stopping timetable monitoring');
_stopTimetableMonitoring();
await _setUserLiveActivityEnabled(false, client: effectiveClient);
await syncGlobalSettingWithCurrentUser(client: effectiveClient);
_logger.info('LiveActivity disabled and user data cleared.');
_logger.info('LiveActivity disabled and user data cleared (device remains in backend for push notifications)');
} else {
_logger.info('Showing privacy consent screen (manual: $isManual)');
final bool? accepted = await _showPrivacyConsentScreen();
@@ -460,9 +506,18 @@ class LiveActivityService {
_logger.info('User accepted privacy policy');
await _setUserLiveActivityEnabled(true, client: effectiveClient);
await syncGlobalSettingWithCurrentUser(client: effectiveClient);
final deviceToken = _cachedDeviceToken ?? await LiveActivityManager.getDeviceToken();
if (deviceToken != null) {
_logger.info('Notifying backend that Live Activity is enabled');
await _backendClient.toggleLiveActivity(
deviceToken: deviceToken,
liveActivityEnabled: true,
);
}
final studentResp = await effectiveClient.getStudent();
final studentName = studentResp.response?.name ?? initData.tokens.first.studentId ?? "Student";
@@ -576,16 +631,6 @@ class LiveActivityService {
}
}
/// Get next Monday's date (or this Monday if today is Monday and it's early morning)
static DateTime _getNextMonday(DateTime now) {
final int daysUntilMonday = ((DateTime.monday - now.weekday) % 7);
final int daysToAdd = daysUntilMonday == 0 ? 7 : daysUntilMonday;
final nextMonday = now.add(Duration(days: daysToAdd));
return DateTime(nextMonday.year, nextMonday.month, nextMonday.day);
}
/// Called when user logs in successfully
/// Registers the device and uploads the *full* timetable
static Future<void> onUserLogin({
@@ -600,11 +645,12 @@ class LiveActivityService {
return;
}
final enabled = await isEnabled(settingsStore, client);
_logger.info('onUserLogin: LiveActivity enabled=$enabled');
final liveActivityEnabled = await isEnabled(settingsStore, client);
final morningNotificationEnabled = _getCurrentMorningNotificationEnabled() ?? false;
_logger.info('onUserLogin: liveActivityEnabled=$liveActivityEnabled, morningNotificationEnabled=$morningNotificationEnabled');
if (!enabled) {
_logger.warning('onUserLogin: LiveActivity not enabled, returning early');
if (!liveActivityEnabled && !morningNotificationEnabled) {
_logger.warning('onUserLogin: Both Live Activity and Morning Notifications are disabled, returning early');
return;
}
@@ -615,59 +661,103 @@ class LiveActivityService {
final startOfWeek = todayStart.subtract(Duration(days: now.weekday - 1));
final endOfWeek = startOfWeek.add(const Duration(days: 6));
_logger.info('onUserLogin: Fetching timetable from $startOfWeek to $endOfWeek');
final timetableResponse = await client.getTimeTable(startOfWeek, endOfWeek);
List<Lesson> allLessons = [];
final allLessons = List<Lesson>.from(timetableResponse.response ?? []);
_logger.info('onUserLogin: Fetched ${allLessons.length} lessons for current week');
try {
_logger.info('onUserLogin: Attempting to fetch fresh timetable 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('onUserLogin: Successfully fetched ${allLessons.length} lessons from KRÉTA API');
} else {
throw Exception('KRÉTA API returned null response');
}
} catch (e) {
_logger.warning('onUserLogin: 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('onUserLogin: Successfully loaded ${allLessons.length} lessons from cache');
} else {
_logger.severe('onUserLogin: Both API and cache failed');
return;
}
} catch (cacheError) {
_logger.severe('onUserLogin: Cache fallback also failed: $cacheError');
return;
}
}
if (allLessons.isEmpty) {
_logger.warning('onUserLogin: No lessons found, returning early');
return;
}
final nextMonday = _getNextMonday(now);
final nextMondayEndOfDay = nextMonday.add(const Duration(days: 1));
_logger.info('Searching for first school day of next week');
_logger.info('Fetching next Monday timetable from $nextMonday to $nextMondayEndOfDay');
bool foundFirstSchoolDay = false;
for (int dayOffset = 1; dayOffset <= 5; dayOffset++) {
final candidateDay = endOfWeek.add(Duration(days: dayOffset));
try {
final nextMondayTimetable = await client.getTimeTable(nextMonday, nextMondayEndOfDay);
final nextMondayLessons = nextMondayTimetable.response ?? [];
_logger.info('Fetched ${nextMondayLessons.length} lessons for next Monday');
if (nextMondayLessons.isNotEmpty) {
nextMondayLessons.sort((a, b) => a.start.compareTo(b.start));
final firstLesson = nextMondayLessons.first;
final notificationLesson = 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(notificationLesson);
_logger.info('Added next Monday first lesson for notification: ${firstLesson.name} at ${firstLesson.start}');
if (candidateDay.weekday == DateTime.saturday || candidateDay.weekday == DateTime.sunday) {
continue;
}
} catch (e) {
_logger.warning('Could not fetch next Monday timetable for notification: $e');
try {
final candidateDayEnd = candidateDay.add(const Duration(days: 1));
final response = await client.getTimeTable(candidateDay, candidateDayEnd, forceCache: false);
if (response.response != null && response.response!.isNotEmpty) {
final schoolLessons = response.response!.where((lesson) {
final uid = lesson.uid.toLowerCase();
return uid.contains('orarendiora') || uid.contains('tanitasiora') || uid.contains('uresora');
}).toList();
if (schoolLessons.isNotEmpty) {
schoolLessons.sort((a, b) => a.start.compareTo(b.start));
final firstLesson = schoolLessons.first;
final notificationLesson = 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(notificationLesson);
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
final dayName = dayNames[candidateDay.weekday - 1];
_logger.info('Added first lesson from next week $dayName (${firstLesson.name}) marked for notification scheduling only');
foundFirstSchoolDay = true;
break;
}
}
} catch (e) {
_logger.warning('Failed to fetch lessons for day offset $dayOffset: $e');
}
}
if (!foundFirstSchoolDay) {
_logger.info('No school lessons found in next week for push notification scheduling');
}
final deviceToken = await _getOrWaitDeviceToken();
@@ -714,6 +804,7 @@ class LiveActivityService {
}
/// Called when app is opened - sends timetable to backend, backend handles updates
/// IMPORTANT: Recreates Live Activity on every app open to refresh the 8-hour push token
static Future<void> onAppOpened({
required KretaClient client,
required String studentName,
@@ -731,13 +822,9 @@ class LiveActivityService {
final activeActivities = await LiveActivityManager.getActiveActivities();
if (activeActivities.isNotEmpty) {
_logger.info('Activity already running, sending timetable update to backend.');
await checkAndUpdateTimetable(
client: client,
studentName: studentName,
settingsStore: settingsStore
);
return;
_logger.info('Ending existing activity to refresh push token (8-hour expiration)');
await LiveActivityManager.endAllActivities();
await Future.delayed(const Duration(milliseconds: 500));
}
final now = DateTime.now();
@@ -749,6 +836,8 @@ class LiveActivityService {
await _startPlaceholderActivity(allLessons, studentName);
_logger.info('New activity created with fresh push token');
await checkAndUpdateTimetable(
client: client,
studentName: studentName,
@@ -800,11 +889,15 @@ class LiveActivityService {
required KretaClient client,
required String studentName,
SettingsStore? settingsStore,
bool forceUpdate = false,
}) async {
if (!Platform.isIOS || !_isInitialized) return;
final enabled = await isEnabled(settingsStore, client);
if (!enabled) {
final liveActivityEnabled = await isEnabled(settingsStore, client);
final morningNotificationEnabled = _getCurrentMorningNotificationEnabled() ?? false;
if (!liveActivityEnabled && !morningNotificationEnabled) {
_logger.info('Both Live Activity and Morning Notifications are disabled, skipping timetable fetch');
return;
}
@@ -814,8 +907,31 @@ class LiveActivityService {
final startOfWeek = todayStart.subtract(Duration(days: now.weekday - 1));
final endOfWeek = startOfWeek.add(const Duration(days: 6));
final timetableResponse = await client.getTimeTable(startOfWeek, endOfWeek);
List<Lesson> allLessons = List<Lesson>.from(timetableResponse.response ?? []);
List<Lesson> allLessons = [];
try {
final timetableResponse = await client.getTimeTable(startOfWeek, endOfWeek, forceCache: false);
if (timetableResponse.response != null) {
allLessons = List<Lesson>.from(timetableResponse.response!);
} else {
throw Exception('KRÉTA API returned null response');
}
} catch (e) {
_logger.warning('checkAndUpdateTimetable: 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!);
} else {
_logger.severe('checkAndUpdateTimetable: Both API and cache failed');
return;
}
} catch (cacheError) {
_logger.severe('checkAndUpdateTimetable: Cache fallback also failed: $cacheError');
return;
}
}
bool foundFirstSchoolDay = false;
for (int dayOffset = 1; dayOffset <= 5; dayOffset++) {
@@ -827,12 +943,12 @@ class LiveActivityService {
try {
final candidateDayEnd = candidateDay.add(const Duration(days: 1));
final response = await client.getTimeTable(candidateDay, candidateDayEnd);
final response = await client.getTimeTable(candidateDay, candidateDayEnd, forceCache: false);
if (response.response != null && response.response!.isNotEmpty) {
final schoolLessons = response.response!.where((lesson) {
final uid = lesson.uid.toLowerCase();
return uid.contains('tanitasiora');
return uid.contains('orarendiora') || uid.contains('tanitasiora') || uid.contains('uresora');
}).toList();
if (schoolLessons.isNotEmpty) {
@@ -954,14 +1070,23 @@ class LiveActivityService {
final deviceToken = await _getOrWaitDeviceToken();
if (deviceToken == null) return;
final lastUpdate = await _getLastUpdate();
final hasChanges = await _backendClient.checkTimetableChanges(
deviceToken: deviceToken,
lastUpdated: lastUpdate,
);
bool shouldUpdate = forceUpdate;
if (hasChanges) {
_logger.info('Timetable changes detected, sending to backend...');
if (!forceUpdate) {
final lastUpdate = await _getLastUpdate();
final hasChanges = await _backendClient.checkTimetableChanges(
deviceToken: deviceToken,
lastUpdated: lastUpdate,
);
shouldUpdate = hasChanges;
}
if (shouldUpdate) {
if (forceUpdate) {
_logger.info('Forcing timetable update (notification settings changed)...');
} else {
_logger.info('Timetable changes detected, sending to backend...');
}
final success = await _backendClient.updateTimetable(
deviceToken: deviceToken,
@@ -1030,10 +1155,12 @@ class LiveActivityService {
/// Starts a minimal placeholder activity shell - backend will update with real data
static Future<void> _startPlaceholderActivity(List<Lesson> allLessons, String studentName) async {
// Always end existing activities to ensure fresh token (8-hour expiration)
final activeActivities = await LiveActivityManager.getActiveActivities();
if (activeActivities.isNotEmpty) {
_logger.info('_startPlaceholderActivity: Activity already running.');
return;
_logger.info('_startPlaceholderActivity: Ending existing activities before creating new one');
await LiveActivityManager.endAllActivities();
await Future.delayed(const Duration(milliseconds: 500));
}
_logger.info('_startPlaceholderActivity: Creating minimal loading shell, backend will update.');
@@ -1253,4 +1380,149 @@ class LiveActivityService {
_logger.severe('Error updating bellDelay: $e');
}
}
/// Handle morning notification enabled change with debounce
/// Waits 3 seconds after the last change before sending update to backend
static void onMorningNotificationEnabledChanged(bool newValue) {
if (!Platform.isIOS || !_isInitialized) return;
_logger.info('Morning notification enabled changed to $newValue, scheduling debounced update');
_morningNotificationDebounceTimer?.cancel();
_pendingMorningNotificationEnabled = newValue;
_morningNotificationDebounceTimer = Timer(_morningNotificationDebounceInterval, () async {
await _sendMorningNotificationUpdate();
});
}
/// Handle morning notification time change with debounce
/// Waits 3 seconds after the last change before sending update to backend
static void onMorningNotificationTimeChanged(double newValue) {
if (!Platform.isIOS || !_isInitialized) return;
_logger.info('Morning notification time changed to $newValue minutes, scheduling debounced update');
_morningNotificationDebounceTimer?.cancel();
_pendingMorningNotificationTime = newValue;
_morningNotificationDebounceTimer = Timer(_morningNotificationDebounceInterval, () async {
await _sendMorningNotificationUpdate();
});
}
/// Internal function to send morning notification settings update to backend
static Future<void> _sendMorningNotificationUpdate() async {
final enabledToSend = _pendingMorningNotificationEnabled ?? _getCurrentMorningNotificationEnabled();
final timeToSend = _pendingMorningNotificationTime ?? _getCurrentMorningNotificationTime();
if (_lastSentMorningNotificationEnabled == enabledToSend &&
_lastSentMorningNotificationTime == timeToSend) {
_logger.info('Morning notification settings already sent to backend, skipping');
_pendingMorningNotificationEnabled = null;
_pendingMorningNotificationTime = null;
return;
}
try {
final deviceToken = await _getOrWaitDeviceToken();
if (deviceToken == null) {
_logger.warning('No device token available to update morning notification settings');
return;
}
_logger.info('Sending morning notification settings update to backend: enabled=$enabledToSend, time=$timeToSend minutes');
final success = await _backendClient.updateMorningNotificationSettings(
deviceToken: deviceToken,
morningNotificationEnabled: enabledToSend,
morningNotificationTime: timeToSend?.toInt(),
);
if (success) {
final wasDisabled = _lastSentMorningNotificationEnabled == false;
final isNowEnabled = enabledToSend == true;
_lastSentMorningNotificationEnabled = enabledToSend;
_lastSentMorningNotificationTime = timeToSend;
_logger.info('Morning notification settings updated successfully in backend');
if (wasDisabled && isNowEnabled) {
_logger.info('Morning notifications re-enabled, fetching timetable to recreate notifications');
try {
final client = initData.client;
final settingsStore = initData.settings;
if (client != null) {
final studentResp = await client.getStudent();
final studentName = studentResp.response?.name ?? client.model?.studentId ?? 'Student';
await checkAndUpdateTimetable(
client: client,
studentName: studentName,
settingsStore: settingsStore,
forceUpdate: true,
);
_logger.info('Timetable fetch completed after re-enabling notifications');
} else {
_logger.warning('Cannot fetch timetable: client is null');
}
} catch (e) {
_logger.severe('Error fetching timetable after re-enabling notifications: $e');
}
}
final currentEnabled = _pendingMorningNotificationEnabled ?? _getCurrentMorningNotificationEnabled();
final currentTime = _pendingMorningNotificationTime ?? _getCurrentMorningNotificationTime();
if (_lastSentMorningNotificationEnabled != currentEnabled ||
_lastSentMorningNotificationTime != currentTime) {
_logger.info('Morning notification settings changed during update, scheduling another update');
_morningNotificationDebounceTimer?.cancel();
_morningNotificationDebounceTimer = Timer(_morningNotificationDebounceInterval, () async {
await _sendMorningNotificationUpdate();
});
} else {
_pendingMorningNotificationEnabled = null;
_pendingMorningNotificationTime = null;
}
} else {
_logger.warning('Failed to update morning notification settings in backend');
}
} catch (e) {
_logger.severe('Error updating morning notification settings: $e');
}
}
/// Get current morning notification enabled value from settings
static bool? _getCurrentMorningNotificationEnabled() {
try {
if (!initDone || initData.settings == null) {
return null;
}
final setting = initData.settings.group("settings")
.subGroup("notifications")["morning_notification_enabled"] as SettingsBoolean?;
return setting?.value;
} catch (e) {
_logger.warning('Error getting current morning notification enabled: $e');
return null;
}
}
/// Get current morning notification time value from settings
static double? _getCurrentMorningNotificationTime() {
try {
if (!initDone || initData.settings == null) {
return null;
}
final setting = initData.settings.group("settings")
.subGroup("notifications")["morning_notification_time"] as SettingsDouble?;
return setting?.value;
} catch (e) {
_logger.warning('Error getting current morning notification time: $e');
return null;
}
}
}

View File

@@ -33,6 +33,8 @@ const themeBrightness = 1017;
const ttToastSubstitution = 1018;
const liveActivityEnabled = 1019;
const liveActivityPrivacyEverDeclined = 1020;
const morningNotificationEnabled = 1021;
const morningNotificationTime = 1022;
bool always() {
return true;
@@ -55,6 +57,22 @@ bool isIOS() {
return Platform.isIOS;
}
bool isLiveActivityEnabled() {
return Platform.isIOS &&
initData.settings
.group("settings")
.subGroup("notifications")
.boolean("live_activity_enabled");
}
bool isMorningNotificationEnabled() {
return Platform.isIOS &&
initData.settings
.group("settings")
.subGroup("notifications")
.boolean("morning_notification_enabled");
}
bool isDebug() {
return kDebugMode;
}
@@ -124,24 +142,6 @@ class SettingsStore {
null),
"left_handed_mode": SettingsBoolean(leftHandedMode, null, null,
l10n.s_ag_left_handed_mode, false, never),
"live_activity_enabled": SettingsBoolean(
liveActivityEnabled,
FirkaIconType.majesticons,
Majesticon.clockSolid,
l10n.la_enable,
false,
isIOS,
() async {
final globalSetting = initData.settings
.group("settings")
.subGroup("application")["live_activity_enabled"] as SettingsBoolean;
final enabled = globalSetting.value;
await LiveActivityService.handleEnabledChange(enabled, isManual: true);
await LiveActivityService.syncGlobalSettingWithCurrentUser();
}),
"live_activity_privacy_ever_declined": SettingsBoolean(
liveActivityPrivacyEverDeclined,
null,
@@ -149,15 +149,6 @@ class SettingsStore {
"Privacy Ever Declined",
false,
never),
"test_notification": SettingsButton(
0,
FirkaIconType.majesticons,
Majesticon.bellSolid,
"Teszt értesítés küldése",
isDebugIOS,
() async {
await LiveActivityService.sendTestNotification();
}),
"language_header":
SettingsHeaderSmall(0, l10n.s_ag_language_header, always),
"language": SettingsItemsRadio(
@@ -304,11 +295,65 @@ class SettingsStore {
0,
FirkaIconType.majesticons,
Majesticon.bellSolid,
"Értesítések",
l10n.s_n,
LinkedHashMap.of({
"back": SettingsBackHeader(0, l10n.s_settings, always),
"settings_header": SettingsHeader(0, l10n.s_n, always),
"settings_padding": SettingsPadding(0, 23, always),
"morning_notification_enabled": SettingsBoolean(
morningNotificationEnabled,
FirkaIconType.majesticons,
Majesticon.bellSolid,
l10n.s_n_morning,
true,
always,
() async {
final setting = initData.settings
.group("settings")
.subGroup("notifications")["morning_notification_enabled"] as SettingsBoolean;
LiveActivityService.onMorningNotificationEnabledChanged(setting.value);
}),
"morning_notification_time": SettingsDouble(
morningNotificationTime,
FirkaIconType.majesticons,
Majesticon.clockSolid,
l10n.s_n_morning_time,
30, // minValue
120, // defaultValue
240, // maxValue
0, // precision (0 = whole numbers)
isMorningNotificationEnabled,
step: 15), // 15 minute steps
"live_activity_enabled": SettingsBoolean(
liveActivityEnabled,
FirkaIconType.majesticons,
Majesticon.clockSolid,
l10n.s_n_live_activity,
false,
always,
() async {
final globalSetting = initData.settings
.group("settings")
.subGroup("notifications")["live_activity_enabled"] as SettingsBoolean;
final enabled = globalSetting.value;
await LiveActivityService.handleEnabledChange(enabled, isManual: true);
await LiveActivityService.syncGlobalSettingWithCurrentUser();
}),
"test_notification": SettingsButton(
0,
FirkaIconType.majesticons,
Majesticon.bellSolid,
l10n.s_n_test,
isDebugIOS,
() async {
await LiveActivityService.sendTestNotification();
}),
}),
never,
isIOS,
null),
"extras": SettingsSubGroup(
0,
@@ -487,6 +532,12 @@ class SettingsStore {
bellDelaySetting.postUpdate = () async {
LiveActivityService.onBellDelayChanged(bellDelaySetting.value);
};
final morningNotificationTimeSetting = group("settings")
.subGroup("notifications")["morning_notification_time"] as SettingsDouble;
morningNotificationTimeSetting.postUpdate = () async {
LiveActivityService.onMorningNotificationTimeChanged(morningNotificationTimeSetting.value);
};
}
}
@@ -1011,6 +1062,7 @@ class SettingsDouble implements SettingsItem {
double defaultValue;
double maxValue = 0.0;
int precision;
double? step;
SettingsDouble(
this.key,
@@ -1021,7 +1073,8 @@ class SettingsDouble implements SettingsItem {
this.defaultValue,
this.maxValue,
this.precision,
this.visibilityProvider);
this.visibilityProvider,
{this.step});
double toRoundedDouble() {
return double.parse(toRoundedString());

View File

@@ -210,17 +210,15 @@ Future<void> _initData(AppInitialization init) async {
await WidgetCacheHelper.updateWidgetCache(appStyle, init.client);
if (Platform.isIOS) {
try {
final studentResp = await init.client.getStudent();
final studentName = studentResp.response?.name ?? token.studentId ?? "Student";
await LiveActivityService.onUserLogin(
client: init.client,
studentName: studentName,
settingsStore: init.settings,
);
} catch (e, st) {
final studentName = token.studentId ?? "Student";
LiveActivityService.onUserLogin(
client: init.client,
studentName: studentName,
settingsStore: init.settings,
).catchError((e, st) {
logger.severe('LiveActivity registration failed: $e', e, st);
}
});
}
}
@@ -304,7 +302,6 @@ void main() async {
logger.finest("Initializing app");
WidgetsFlutterBinding.ensureInitialized();
// Load environment variables from .env file
await dotenv.load(fileName: ".env");
logger.info("Environment variables loaded");

View File

@@ -916,12 +916,19 @@ void showSetDoubleSheet(BuildContext context, SettingsDouble setting,
min: setting.minValue,
value: setting.value,
max: setting.maxValue,
divisions: setting.step != null
? ((setting.maxValue - setting.minValue) / setting.step!).round()
: null,
thumbColor: appStyle.colors.accent,
activeColor: appStyle.colors.secondary,
inactiveColor: appStyle.colors.a15p,
onChanged: (v) async {
setState(() {
setting.value = v;
if (setting.step != null) {
setting.value = (v / setting.step!).round() * setting.step!;
} else {
setting.value = v;
}
setting.value =
setting.toRoundedDouble();
});