forked from firka/firka
Add morning notification settings and debounce handling for Live Activities
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user