From b8058cd4cb6cee2c0b1d7744ecd86328f3db00ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20Gergely?= Date: Fri, 12 Dec 2025 23:59:32 +0100 Subject: [PATCH] Add morning notification settings and debounce handling for Live Activities --- .../LiveActivityMethodChannelManager.swift | 11 +- .../Runner/TimetableActivityAttributes.swift | 106 ++-- .../TimetableActivityAttributes.swift | 25 +- .../TimetableLiveActivity.swift | 118 ++-- .../client/live_activity_backend_client.dart | 56 ++ firka/lib/helpers/live_activity_manager.dart | 10 +- firka/lib/helpers/live_activity_service.dart | 510 ++++++++++++++---- firka/lib/helpers/settings.dart | 113 ++-- firka/lib/main.dart | 19 +- .../screens/settings/settings_screen.dart | 9 +- 10 files changed, 687 insertions(+), 290 deletions(-) diff --git a/firka/ios/Runner/LiveActivityMethodChannelManager.swift b/firka/ios/Runner/LiveActivityMethodChannelManager.swift index 2ee06a9e..d992399c 100644 --- a/firka/ios/Runner/LiveActivityMethodChannelManager.swift +++ b/firka/ios/Runner/LiveActivityMethodChannelManager.swift @@ -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.activities.first { - await existingActivity.update(ActivityContent(state: contentState, staleDate: nil)) - result(existingActivity.id) - return + let existingActivities = Activity.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) } } -} \ No newline at end of file +} diff --git a/firka/ios/Runner/TimetableActivityAttributes.swift b/firka/ios/Runner/TimetableActivityAttributes.swift index 65365051..b1710a02 100644 --- a/firka/ios/Runner/TimetableActivityAttributes.swift +++ b/firka/ios/Runner/TimetableActivityAttributes.swift @@ -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 + ) - + } } - \ No newline at end of file + diff --git a/firka/ios/TimetableWidget/TimetableActivityAttributes.swift b/firka/ios/TimetableWidget/TimetableActivityAttributes.swift index 83bd07c0..704f98f0 100644 --- a/firka/ios/TimetableWidget/TimetableActivityAttributes.swift +++ b/firka/ios/TimetableWidget/TimetableActivityAttributes.swift @@ -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 ) } } diff --git a/firka/ios/TimetableWidget/TimetableLiveActivity.swift b/firka/ios/TimetableWidget/TimetableLiveActivity.swift index 410c0963..bc47f153 100644 --- a/firka/ios/TimetableWidget/TimetableLiveActivity.swift +++ b/firka/ios/TimetableWidget/TimetableLiveActivity.swift @@ -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) } - } -} \ No newline at end of file +} diff --git a/firka/lib/helpers/api/client/live_activity_backend_client.dart b/firka/lib/helpers/api/client/live_activity_backend_client.dart index ecc8633d..d319a033 100644 --- a/firka/lib/helpers/api/client/live_activity_backend_client.dart +++ b/firka/lib/helpers/api/client/live_activity_backend_client.dart @@ -366,5 +366,61 @@ class LiveActivityBackendClient { return false; } } + + /// Update morning notification settings for device + Future 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 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; + } + } } diff --git a/firka/lib/helpers/live_activity_manager.dart b/firka/lib/helpers/live_activity_manager.dart index 35e4c59f..7d385d1f 100644 --- a/firka/lib/helpers/live_activity_manager.dart +++ b/firka/lib/helpers/live_activity_manager.dart @@ -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( diff --git a/firka/lib/helpers/live_activity_service.dart b/firka/lib/helpers/live_activity_service.dart index 92d0add5..3ca0e880 100644 --- a/firka/lib/helpers/live_activity_service.dart +++ b/firka/lib/helpers/live_activity_service.dart @@ -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 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 allLessons = []; - final allLessons = List.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.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.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 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 allLessons = List.from(timetableResponse.response ?? []); + List allLessons = []; + + try { + final timetableResponse = await client.getTimeTable(startOfWeek, endOfWeek, forceCache: false); + + if (timetableResponse.response != null) { + allLessons = List.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.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 _startPlaceholderActivity(List 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 _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; + } + } } \ No newline at end of file diff --git a/firka/lib/helpers/settings.dart b/firka/lib/helpers/settings.dart index 23e69d49..bc7eb5bf 100644 --- a/firka/lib/helpers/settings.dart +++ b/firka/lib/helpers/settings.dart @@ -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()); diff --git a/firka/lib/main.dart b/firka/lib/main.dart index f32b8f7c..b5766b5e 100644 --- a/firka/lib/main.dart +++ b/firka/lib/main.dart @@ -210,17 +210,15 @@ Future _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"); diff --git a/firka/lib/ui/phone/screens/settings/settings_screen.dart b/firka/lib/ui/phone/screens/settings/settings_screen.dart index 28cae919..2a2feb91 100644 --- a/firka/lib/ui/phone/screens/settings/settings_screen.dart +++ b/firka/lib/ui/phone/screens/settings/settings_screen.dart @@ -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(); });