Implement token expiration handling and reauthentication UI; add reauth toast and update seasonal icon logic

This commit is contained in:
Horváth Gergely
2025-12-19 14:51:12 +01:00
parent 229eabfd4f
commit 8d768ca6b8
9 changed files with 374 additions and 813 deletions

View File

@@ -1,637 +0,0 @@
import ActivityKit
import Foundation
struct TimetableActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var isBreak: Bool
var lessonName: String
var lessonTheme: String?
var roomName: String?
var teacherName: String?
var startTime: Date
var endTime: Date
var lessonNumber: Int?
var mode: String? // "lesson" | "break" | "seasonalBreak" | "xmas" | "newYearEve" | "newYearDay"
var message: String?
var season: String?
var nextLessonName: String?
var nextRoomName: String?
var nextStartTime: Date?
var isSubstitution: Bool?
var isCancelled: Bool?
var substituteTeacher: String?
var currentTime: Date
var tokenExpirationWarning: String?
enum CodingKeys: String, CodingKey {
case isBreak
case lessonName
case lessonTheme
case roomName
case teacherName
case startTime
case endTime
case lessonNumber
case mode
case message
case season
case nextLessonName
case nextRoomName
case nextStartTime
case isSubstitution
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, 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
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
isBreak = try container.decode(Bool.self, forKey: .isBreak)
lessonName = try container.decode(String.self, forKey: .lessonName)
lessonTheme = try container.decodeIfPresent(String.self, forKey: .lessonTheme)
roomName = try container.decodeIfPresent(String.self, forKey: .roomName)
teacherName = try container.decodeIfPresent(String.self, forKey: .teacherName)
let startTimeStr = try container.decode(String.self, forKey: .startTime)
guard let startTimeDate = isoFormatter.date(from: startTimeStr) else {
throw DecodingError.dataCorruptedError(forKey: .startTime, in: container, debugDescription: "Invalid startTime format: \(startTimeStr)")
}
startTime = startTimeDate
let endTimeStr = try container.decode(String.self, forKey: .endTime)
guard let endTimeDate = isoFormatter.date(from: endTimeStr) else {
throw DecodingError.dataCorruptedError(forKey: .endTime, in: container, debugDescription: "Invalid endTime format: \(endTimeStr)")
}
endTime = endTimeDate
lessonNumber = try container.decodeIfPresent(Int.self, forKey: .lessonNumber)
mode = try container.decodeIfPresent(String.self, forKey: .mode)
message = try container.decodeIfPresent(String.self, forKey: .message)
season = try container.decodeIfPresent(String.self, forKey: .season)
nextLessonName = try container.decodeIfPresent(String.self, forKey: .nextLessonName)
nextRoomName = try container.decodeIfPresent(String.self, forKey: .nextRoomName)
if let nextStartTimeStr = try container.decodeIfPresent(String.self, forKey: .nextStartTime) {
nextStartTime = isoFormatter.date(from: nextStartTimeStr)
} else {
nextStartTime = nil
}
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)
guard let currentTimeDate = isoFormatter.date(from: currentTimeStr) else {
throw DecodingError.dataCorruptedError(forKey: .currentTime, in: container, debugDescription: "Invalid currentTime format: \(currentTimeStr)")
}
currentTime = currentTimeDate
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
try container.encode(isBreak, forKey: .isBreak)
try container.encode(lessonName, forKey: .lessonName)
try container.encodeIfPresent(lessonTheme, forKey: .lessonTheme)
try container.encodeIfPresent(roomName, forKey: .roomName)
try container.encodeIfPresent(teacherName, forKey: .teacherName)
try container.encode(isoFormatter.string(from: startTime), forKey: .startTime)
try container.encode(isoFormatter.string(from: endTime), forKey: .endTime)
try container.encodeIfPresent(lessonNumber, forKey: .lessonNumber)
try container.encodeIfPresent(mode, forKey: .mode)
try container.encodeIfPresent(message, forKey: .message)
try container.encodeIfPresent(season, forKey: .season)
try container.encodeIfPresent(nextLessonName, forKey: .nextLessonName)
try container.encodeIfPresent(nextRoomName, forKey: .nextRoomName)
if let nextStartTime = nextStartTime {
try container.encode(isoFormatter.string(from: nextStartTime), forKey: .nextStartTime)
}
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)
}
}
var studentName: String
var schoolName: String
}
extension TimetableActivityAttributes.ContentState {
var timeRemaining: TimeInterval {
return endTime.timeIntervalSince(currentTime)
}
var isBeforeSchool: Bool {
return currentTime < startTime && !isBreak
}
var formattedStartTime: String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
formatter.timeZone = TimeZone(identifier: "UTC")
let adjustedDate = Calendar.current.date(byAdding: .hour, value: 1, to: startTime) ?? startTime
return formatter.string(from: adjustedDate)
}
var formattedEndTime: String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
formatter.timeZone = TimeZone(identifier: "UTC")
let adjustedDate = Calendar.current.date(byAdding: .hour, value: 1, to: endTime) ?? endTime
return formatter.string(from: adjustedDate)
}
var formattedNextStartTime: String {
guard let nextStartTime = nextStartTime else { return "" }
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
formatter.timeZone = TimeZone(identifier: "UTC")
let adjustedDate = Calendar.current.date(byAdding: .hour, value: 1, to: nextStartTime) ?? nextStartTime
return formatter.string(from: adjustedDate)
}
var timeRemainingText: String {
let remaining = timeRemaining
if remaining < 0 {
return "0:00"
}
let hours = Int(remaining) / 3600
let minutes = (Int(remaining) % 3600) / 60
let seconds = Int(remaining) % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else if minutes > 0 {
return String(format: "%d:%02d", minutes, seconds)
} else {
return String(format: "0:%02d", seconds)
}
}
var seasonalRemainingText: String {
let remaining = max(0, timeRemaining)
let hours = Int(remaining) / 3600
if hours >= 24 {
let days = hours / 24
return "Szünetből hátralévő idő: \(days) nap"
}
return "Szünetből hátralévő idő: \(hours) óra"
}
var seasonalDisplayValue: String {
let remaining = max(0, timeRemaining)
let hours = Int(remaining) / 3600
if hours >= 24 {
let days = hours / 24
return "\(days) nap"
}
return "\(hours) óra"
}
}
extension TimetableActivityAttributes.ContentState {
func toJSON() -> [String: Any] {
var json: [String: Any] = [
"isBreak": isBreak,
"lessonName": lessonName,
"startTime": ISO8601DateFormatter().string(from: startTime),
"endTime": ISO8601DateFormatter().string(from: endTime),
"currentTime": ISO8601DateFormatter().string(from: currentTime)
]
if let isSubstitution = isSubstitution {
json["isSubstitution"] = isSubstitution
}
if let isCancelled = isCancelled {
json["isCancelled"] = isCancelled
}
if let lessonTheme = lessonTheme {
json["lessonTheme"] = lessonTheme
}
if let roomName = roomName {
json["roomName"] = roomName
}
if let teacherName = teacherName {
json["teacherName"] = teacherName
}
if let lessonNumber = lessonNumber {
json["lessonNumber"] = lessonNumber
}
if let nextLessonName = nextLessonName {
json["nextLessonName"] = nextLessonName
}
if let nextRoomName = nextRoomName {
json["nextRoomName"] = nextRoomName
}
if let nextStartTime = nextStartTime {
json["nextStartTime"] = ISO8601DateFormatter().string(from: nextStartTime)
}
if let substituteTeacher = substituteTeacher {
json["substituteTeacher"] = substituteTeacher
}
if let mode = mode {
json["mode"] = mode
}
if let message = message {
json["message"] = message
}
if let season = season {
json["season"] = season
}
if let tokenExpirationWarning = tokenExpirationWarning {
json["tokenExpirationWarning"] = tokenExpirationWarning
}
return json
}
static func fromJSON(_ json: [String: Any]) -> TimetableActivityAttributes.ContentState? {
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let isBreak = json["isBreak"] as? Bool,
let lessonName = json["lessonName"] as? String,
let startTimeStr = json["startTime"] as? String,
let endTimeStr = json["endTime"] as? String,
let startTime = isoFormatter.date(from: startTimeStr),
let endTime = isoFormatter.date(from: endTimeStr) else {
return nil
}
let currentTimeStr = json["currentTime"] as? String
let currentTime = currentTimeStr.flatMap { isoFormatter.date(from: $0) } ?? Date()
let nextStartTime: Date?
if let nextStartTimeStr = json["nextStartTime"] as? String {
nextStartTime = isoFormatter.date(from: nextStartTimeStr)
} else {
nextStartTime = nil
}
return TimetableActivityAttributes.ContentState(
isBreak: isBreak,
lessonName: lessonName,
lessonTheme: json["lessonTheme"] as? String,
roomName: json["roomName"] as? String,
teacherName: json["teacherName"] as? String,
startTime: startTime,
endTime: endTime,
lessonNumber: json["lessonNumber"] as? Int,
mode: json["mode"] as? String,
message: json["message"] as? String,
season: json["season"] as? String,
nextLessonName: json["nextLessonName"] as? String,
nextRoomName: json["nextRoomName"] as? String,
nextStartTime: nextStartTime,
isSubstitution: json["isSubstitution"] as? Bool,
isCancelled: json["isCancelled"] as? Bool,
substituteTeacher: json["substituteTeacher"] as? String,
currentTime: currentTime,
tokenExpirationWarning: json["tokenExpirationWarning"] as? String
)
}
}

View File

@@ -1,11 +1,11 @@
import SwiftUI
struct SeasonalIconHelper {
static func iconName(for mode: String?, season: String?) -> String {
static func iconName(for mode: String?, season: String?, lessonIcon: String? = nil) -> String {
guard let mode = mode else {
return "book.fill"
return lessonIcon ?? "book.fill"
}
switch mode {
case "xmas":
return "gift.fill"
@@ -33,26 +33,55 @@ struct SeasonalIconHelper {
default:
return "snowflake"
}
case "lesson":
return lessonIcon ?? "book.fill"
default:
return "book.fill"
return lessonIcon ?? "book.fill"
}
}
static func iconColor(for mode: String?) -> Color {
static func iconColor(for mode: String?, season: String? = nil) -> Color {
guard let mode = mode else {
return .green
}
switch mode {
case "beforeSchool":
return .orange
case "xmas", "newYearEve", "newYearDay", "seasonalBreak":
return .green
case "xmas":
return .red
case "newYearEve":
return .purple
case "newYearDay":
return .mint
case "seasonalBreak":
return seasonColor(for: season)
default:
return .green
}
}
static func seasonColor(for season: String?) -> Color {
guard let season = season else {
return .blue
}
switch season {
case "spring":
return .green
case "summer":
return .blue
case "autumn":
return .orange
case "winter":
return .cyan
case "other":
return .blue
default:
return .blue
}
}
static func isSeasonalMode(_ mode: String?) -> Bool {
guard let mode = mode else {
return false

View File

@@ -1,85 +0,0 @@
import Foundation
import ActivityKit
struct TimeFormatHelper {
static func compactTime(remaining: TimeInterval, labels: TimetableActivityAttributes.ContentState.Labels?) -> String {
let lang = detectLanguage(from: labels)
let hours = Int(max(0, remaining)) / 3600
let minutes = (Int(max(0, remaining)) % 3600) / 60
if hours >= 1 {
return shortHours(hours, language: lang)
} else {
return shortMinutes(minutes, language: lang)
}
}
static func compactSeasonalBreak(from message: String, labels: TimetableActivityAttributes.ContentState.Labels?) -> String {
let lang = detectLanguage(from: labels)
let components = message.split(separator: " ")
let number = components.first.map(String.init) ?? ""
let isDay = message.lowercased().contains("day") ||
message.lowercased().contains("nap") ||
message.lowercased().contains("tag")
if isDay {
return shortDays(Int(number) ?? 0, language: lang)
} else {
return shortHours(Int(number) ?? 0, language: lang)
}
}
// MARK: - Language Detection
private static func detectLanguage(from labels: TimetableActivityAttributes.ContentState.Labels?) -> String {
guard let labels = labels else {
return Locale.current.languageCode ?? "hu"
}
if let text = labels.remainingLabel ?? labels.cancelledText {
let lower = text.lowercased()
if lower.contains("time") || lower.contains("remaining") || lower.contains("cancelled") {
return "en"
} else if lower.contains("verbleibende") || lower.contains("zeit") || lower.contains("entfallen") {
return "de"
} else {
return "hu"
}
}
return Locale.current.languageCode ?? "hu"
}
// MARK: - Private Helpers
private static func shortHours(_ hours: Int, language: String) -> String {
switch language {
case "en": return "\(hours)h"
case "de": return "\(hours)td"
case "hu": return "\(hours)ó"
default: return "\(hours)h"
}
}
private static func shortMinutes(_ minutes: Int, language: String) -> String {
switch language {
case "en": return "\(minutes)m"
case "de": return "\(minutes)Min"
case "hu": return "\(minutes)p"
default: return "\(minutes)m"
}
}
private static func shortDays(_ days: Int, language: String) -> String {
switch language {
case "en": return "\(days)d"
case "de": return "\(days)T"
case "hu": return "\(days)n"
default: return "\(days)d"
}
}
}

View File

@@ -28,6 +28,8 @@ struct TimetableActivityAttributes: ActivityAttributes {
var labels: Labels?
var tokenExpirationWarning: String?
var compactTimerText: String?
var lessonIcon: String?
struct Labels: Codable, Hashable {
var title: String?
@@ -66,9 +68,11 @@ struct TimetableActivityAttributes: ActivityAttributes {
case currentTime
case labels
case tokenExpirationWarning
case compactTimerText
case lessonIcon
}
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) {
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, compactTimerText: String? = nil, lessonIcon: String? = nil) {
self.isBreak = isBreak
self.lessonName = lessonName
self.lessonTheme = lessonTheme
@@ -89,50 +93,54 @@ struct TimetableActivityAttributes: ActivityAttributes {
self.currentTime = currentTime
self.labels = labels
self.tokenExpirationWarning = tokenExpirationWarning
self.compactTimerText = compactTimerText
self.lessonIcon = lessonIcon
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
isBreak = try container.decode(Bool.self, forKey: .isBreak)
lessonName = try container.decode(String.self, forKey: .lessonName)
lessonTheme = try container.decodeIfPresent(String.self, forKey: .lessonTheme)
roomName = try container.decodeIfPresent(String.self, forKey: .roomName)
teacherName = try container.decodeIfPresent(String.self, forKey: .teacherName)
let startTimeStr = try container.decode(String.self, forKey: .startTime)
guard let startTimeDate = isoFormatter.date(from: startTimeStr) else {
throw DecodingError.dataCorruptedError(forKey: .startTime, in: container, debugDescription: "Invalid startTime format: \(startTimeStr)")
}
startTime = startTimeDate
let endTimeStr = try container.decode(String.self, forKey: .endTime)
guard let endTimeDate = isoFormatter.date(from: endTimeStr) else {
throw DecodingError.dataCorruptedError(forKey: .endTime, in: container, debugDescription: "Invalid endTime format: \(endTimeStr)")
}
endTime = endTimeDate
lessonNumber = try container.decodeIfPresent(Int.self, forKey: .lessonNumber)
mode = try container.decodeIfPresent(String.self, forKey: .mode)
message = try container.decodeIfPresent(String.self, forKey: .message)
season = try container.decodeIfPresent(String.self, forKey: .season)
nextLessonName = try container.decodeIfPresent(String.self, forKey: .nextLessonName)
nextRoomName = try container.decodeIfPresent(String.self, forKey: .nextRoomName)
if let nextStartTimeStr = try container.decodeIfPresent(String.self, forKey: .nextStartTime) {
nextStartTime = isoFormatter.date(from: nextStartTimeStr)
} else {
nextStartTime = nil
}
isSubstitution = try container.decode(Bool.self, forKey: .isSubstitution)
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)
compactTimerText = try container.decodeIfPresent(String.self, forKey: .compactTimerText)
lessonIcon = try container.decodeIfPresent(String.self, forKey: .lessonIcon)
let currentTimeStr = try container.decode(String.self, forKey: .currentTime)
guard let currentTimeDate = isoFormatter.date(from: currentTimeStr) else {
@@ -140,43 +148,45 @@ struct TimetableActivityAttributes: ActivityAttributes {
}
currentTime = currentTimeDate
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
try container.encode(isBreak, forKey: .isBreak)
try container.encode(lessonName, forKey: .lessonName)
try container.encodeIfPresent(lessonTheme, forKey: .lessonTheme)
try container.encodeIfPresent(roomName, forKey: .roomName)
try container.encodeIfPresent(teacherName, forKey: .teacherName)
try container.encode(isoFormatter.string(from: startTime), forKey: .startTime)
try container.encode(isoFormatter.string(from: endTime), forKey: .endTime)
try container.encodeIfPresent(lessonNumber, forKey: .lessonNumber)
try container.encodeIfPresent(mode, forKey: .mode)
try container.encodeIfPresent(message, forKey: .message)
try container.encodeIfPresent(season, forKey: .season)
try container.encodeIfPresent(nextLessonName, forKey: .nextLessonName)
try container.encodeIfPresent(nextRoomName, forKey: .nextRoomName)
if let nextStartTime = nextStartTime {
try container.encode(isoFormatter.string(from: nextStartTime), forKey: .nextStartTime)
}
try container.encode(isSubstitution, forKey: .isSubstitution)
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.encodeIfPresent(compactTimerText, forKey: .compactTimerText)
try container.encodeIfPresent(lessonIcon, forKey: .lessonIcon)
try container.encode(isoFormatter.string(from: currentTime), forKey: .currentTime)
}
}
var studentName: String
var schoolName: String
}
@@ -185,11 +195,11 @@ extension TimetableActivityAttributes.ContentState {
var timeRemaining: TimeInterval {
return endTime.timeIntervalSince(currentTime)
}
var isBeforeSchool: Bool {
return currentTime < startTime && !isBreak
}
var formattedStartTime: String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
@@ -197,7 +207,7 @@ extension TimetableActivityAttributes.ContentState {
let adjustedDate = Calendar.current.date(byAdding: .hour, value: 1, to: startTime) ?? startTime
return formatter.string(from: adjustedDate)
}
var formattedEndTime: String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
@@ -205,7 +215,7 @@ extension TimetableActivityAttributes.ContentState {
let adjustedDate = Calendar.current.date(byAdding: .hour, value: 1, to: endTime) ?? endTime
return formatter.string(from: adjustedDate)
}
var formattedNextStartTime: String {
guard let nextStartTime = nextStartTime else { return "" }
let formatter = DateFormatter()
@@ -214,18 +224,18 @@ extension TimetableActivityAttributes.ContentState {
let adjustedDate = Calendar.current.date(byAdding: .hour, value: 1, to: nextStartTime) ?? nextStartTime
return formatter.string(from: adjustedDate)
}
var timeRemainingText: String {
let remaining = timeRemaining
if remaining < 0 {
return "0:00"
}
let hours = Int(remaining) / 3600
let minutes = (Int(remaining) % 3600) / 60
let seconds = Int(remaining) % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else if minutes > 0 {
@@ -234,7 +244,7 @@ extension TimetableActivityAttributes.ContentState {
return String(format: "0:%02d", seconds)
}
}
var seasonalRemainingText: String {
if let remainingLabel = labels?.remainingLabel, let message = message, !message.isEmpty {
return "\(remainingLabel): \(message)"
@@ -248,7 +258,7 @@ extension TimetableActivityAttributes.ContentState {
}
return "Szünetből hátralévő idő: \(hours) óra"
}
var seasonalDisplayValue: String {
if let message = message, !message.isEmpty {
return message
@@ -275,7 +285,7 @@ extension TimetableActivityAttributes.ContentState {
"isCancelled": isCancelled,
"currentTime": ISO8601DateFormatter().string(from: currentTime)
]
if let lessonTheme = lessonTheme {
json["lessonTheme"] = lessonTheme
}
@@ -312,10 +322,16 @@ extension TimetableActivityAttributes.ContentState {
if let tokenExpirationWarning = tokenExpirationWarning {
json["tokenExpirationWarning"] = tokenExpirationWarning
}
if let compactTimerText = compactTimerText {
json["compactTimerText"] = compactTimerText
}
if let lessonIcon = lessonIcon {
json["lessonIcon"] = lessonIcon
}
return json
}
static func fromJSON(_ json: [String: Any]) -> TimetableActivityAttributes.ContentState? {
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
@@ -333,7 +349,7 @@ extension TimetableActivityAttributes.ContentState {
let currentTimeStr = json["currentTime"] as? String
let currentTime = currentTimeStr.flatMap { isoFormatter.date(from: $0) } ?? Date()
let nextStartTime: Date?
if let nextStartTimeStr = json["nextStartTime"] as? String {
nextStartTime = isoFormatter.date(from: nextStartTimeStr)
@@ -382,8 +398,9 @@ extension TimetableActivityAttributes.ContentState {
substituteTeacher: json["substituteTeacher"] as? String,
currentTime: currentTime,
labels: labels,
tokenExpirationWarning: json["tokenExpirationWarning"] as? String
tokenExpirationWarning: json["tokenExpirationWarning"] as? String,
compactTimerText: json["compactTimerText"] as? String,
lessonIcon: json["lessonIcon"] as? String
)
}
}

View File

@@ -45,9 +45,9 @@ struct TimetableLiveActivity: Widget {
VStack(spacing: 4) {
if mode == "beforeSchool" {
HStack(alignment: .center, spacing: 6) {
Image(systemName: SeasonalIconHelper.iconName(for: mode, season: season))
Image(systemName: SeasonalIconHelper.iconName(for: mode, season: season, lessonIcon: context.state.lessonIcon))
.font(.system(size: 18))
.foregroundColor(SeasonalIconHelper.iconColor(for: mode))
.foregroundColor(SeasonalIconHelper.iconColor(for: mode, season: season))
Text(context.state.labels?.title ?? "Hamarosan suli")
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
@@ -90,7 +90,7 @@ struct TimetableLiveActivity: Widget {
HStack(alignment: .center, spacing: 6) {
Image(systemName: SeasonalIconHelper.iconName(for: mode, season: season))
.font(.system(size: 18))
.foregroundColor(SeasonalIconHelper.iconColor(for: mode))
.foregroundColor(SeasonalIconHelper.iconColor(for: mode, season: season))
Text(context.state.message ?? "Szünet")
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
@@ -100,7 +100,7 @@ struct TimetableLiveActivity: Widget {
HStack(alignment: .center, spacing: 6) {
Image(systemName: SeasonalIconHelper.iconName(for: mode, season: season))
.font(.system(size: 18))
.foregroundColor(SeasonalIconHelper.iconColor(for: mode))
.foregroundColor(SeasonalIconHelper.iconColor(for: mode, season: season))
Text(context.state.labels?.title ?? context.state.lessonName)
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
@@ -111,7 +111,7 @@ struct TimetableLiveActivity: Widget {
HStack(alignment: .center, spacing: 6) {
Image(systemName: "cup.and.saucer.fill")
.font(.system(size: 18))
.foregroundColor(SeasonalIconHelper.iconColor(for: mode))
.foregroundColor(SeasonalIconHelper.iconColor(for: mode, season: season))
Text(context.state.labels?.title ?? "Szünet")
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
@@ -173,6 +173,7 @@ struct TimetableLiveActivity: Widget {
}
DynamicIslandExpandedRegion(.bottom) {
let season = context.state.season ?? ""
VStack(spacing: 4) {
HStack {
Spacer()
@@ -182,20 +183,20 @@ struct TimetableLiveActivity: Widget {
if context.state.endTime > context.state.currentTime {
Text(timerInterval: context.state.currentTime...context.state.endTime, countsDown: true)
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundColor(.green)
.foregroundColor(SeasonalIconHelper.iconColor(for: mode, season: season))
.multilineTextAlignment(.center)
.monospacedDigit()
} else {
Text(context.state.formattedEndTime)
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundColor(.green)
.foregroundColor(SeasonalIconHelper.iconColor(for: mode, season: season))
.multilineTextAlignment(.center)
.monospacedDigit()
}
} else if mode == "seasonalBreak" {
Text(context.state.seasonalRemainingText)
.font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundColor(.green)
.foregroundColor(SeasonalIconHelper.iconColor(for: mode, season: season))
.multilineTextAlignment(.center)
.monospacedDigit()
} else if context.state.isCancelled ?? false {
@@ -206,7 +207,7 @@ struct TimetableLiveActivity: Widget {
} else {
Text(timerInterval: context.state.currentTime...context.state.endTime, countsDown: true)
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundColor(.green)
.foregroundColor(SeasonalIconHelper.iconColor(for: mode, season: season))
.multilineTextAlignment(.center)
.monospacedDigit()
}
@@ -234,59 +235,44 @@ struct TimetableLiveActivity: Widget {
let season = context.state.season ?? ""
let iconName: String = {
if SeasonalIconHelper.isSeasonalMode(mode) || mode == "beforeSchool" {
return SeasonalIconHelper.iconName(for: mode, season: season)
return SeasonalIconHelper.iconName(for: mode, season: season, lessonIcon: context.state.lessonIcon)
} else {
return context.state.isBreak ? "cup.and.saucer.fill" : "book.fill"
return context.state.isBreak ? "cup.and.saucer.fill" : (context.state.lessonIcon ?? "book.fill")
}
}()
Image(systemName: iconName)
.font(.system(size: 14))
.foregroundColor(SeasonalIconHelper.iconColor(for: mode))
.foregroundColor(SeasonalIconHelper.iconColor(for: mode, season: season))
} compactTrailing: {
if mode == "seasonalBreak" {
// Seasonal break: static short format from backend message
let compactText = TimeFormatHelper.compactSeasonalBreak(
from: context.state.message ?? "",
labels: context.state.labels
)
Text(compactText)
.font(.system(size: 12, weight: .semibold, design: .rounded))
.foregroundColor(.green)
.frame(width: 50)
} else if mode == "xmas" || mode == "newYearDay" {
let season = context.state.season ?? ""
if mode == "xmas" || mode == "newYearDay" {
Text("")
.frame(width: 50)
} else if context.state.isCancelled ?? false {
Text("")
.font(.system(size: 12, weight: .semibold))
.frame(width: 50)
} else if let compactText = context.state.compactTimerText {
Text(compactText)
.font(.system(size: 12, weight: .semibold, design: .rounded))
.foregroundColor(SeasonalIconHelper.iconColor(for: mode, season: season))
.frame(width: 50)
} else {
TimelineView(.periodic(from: context.state.currentTime, by: 60.0)) { timeline in
let remaining = context.state.endTime.timeIntervalSince(timeline.date)
let compactText = TimeFormatHelper.compactTime(
remaining: remaining,
labels: context.state.labels
)
Text(compactText)
.font(.system(size: 12, weight: .semibold, design: .rounded))
.foregroundColor(.green)
.frame(width: 50)
}
Text("")
.frame(width: 50)
}
} minimal: {
let season = context.state.season ?? ""
let iconName: String = {
if SeasonalIconHelper.isSeasonalMode(mode) || mode == "beforeSchool" {
return SeasonalIconHelper.iconName(for: mode, season: season)
return SeasonalIconHelper.iconName(for: mode, season: season, lessonIcon: context.state.lessonIcon)
} else {
return context.state.isBreak ? "cup.and.saucer.fill" : "book.fill"
return context.state.isBreak ? "cup.and.saucer.fill" : (context.state.lessonIcon ?? "book.fill")
}
}()
Image(systemName: iconName)
.font(.system(size: 12))
.foregroundColor(SeasonalIconHelper.iconColor(for: mode))
.foregroundColor(SeasonalIconHelper.iconColor(for: mode, season: season))
}
}
}
@@ -310,17 +296,17 @@ struct TimetableLiveActivityView: View {
}() : nil
let iconName: String = {
if SeasonalIconHelper.isSeasonalMode(mode) || mode == "beforeSchool" {
return SeasonalIconHelper.iconName(for: mode, season: season)
return SeasonalIconHelper.iconName(for: mode, season: season, lessonIcon: context.state.lessonIcon)
} else {
return context.state.isBreak ? "cup.and.saucer.fill" : "book.fill"
return context.state.isBreak ? "cup.and.saucer.fill" : (context.state.lessonIcon ?? "book.fill")
}
}()
// Header
HStack {
Image(systemName: iconName)
.font(.system(size: 24))
.foregroundColor(SeasonalIconHelper.iconColor(for: mode))
.foregroundColor(SeasonalIconHelper.iconColor(for: mode, season: season))
VStack(alignment: .leading, spacing: 2) {
if mode == "beforeSchool" {
@@ -496,17 +482,17 @@ struct TimetableLiveActivityView: View {
Text(context.state.labels?.timerLabel ?? "Első óra kezdése")
.font(.system(size: 10))
.foregroundColor(.gray)
if context.state.endTime > context.state.currentTime {
Text(timerInterval: context.state.currentTime...context.state.endTime, countsDown: true)
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundColor(.green)
.foregroundColor(SeasonalIconHelper.iconColor(for: mode3, season: season))
.multilineTextAlignment(.center)
.monospacedDigit()
} else {
Text(context.state.formattedEndTime)
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundColor(.green)
.foregroundColor(SeasonalIconHelper.iconColor(for: mode3, season: season))
.multilineTextAlignment(.center)
.monospacedDigit()
}
@@ -516,7 +502,7 @@ struct TimetableLiveActivityView: View {
.foregroundColor(.gray)
Text(context.state.seasonalDisplayValue)
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundColor(.green)
.foregroundColor(SeasonalIconHelper.iconColor(for: mode3, season: season))
.multilineTextAlignment(.center)
.monospacedDigit()
} else {
@@ -542,7 +528,7 @@ struct TimetableLiveActivityView: View {
.foregroundColor(.gray)
Text(timerInterval: context.state.currentTime...context.state.endTime, countsDown: true)
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundColor(.green)
.foregroundColor(SeasonalIconHelper.iconColor(for: mode3, season: season))
.multilineTextAlignment(.center)
.monospacedDigit()
}

View File

@@ -41,6 +41,17 @@ class LiveActivityService {
static bool? _lastSentMorningNotificationEnabled;
static const Duration _morningNotificationDebounceInterval = Duration(seconds: 3);
static bool _tokenExpired = false;
/// Check if token has expired (for UI notification)
static bool get isTokenExpired => _tokenExpired;
/// Clear token expiration flag (call after successful login)
static void clearTokenExpiration() {
_tokenExpired = false;
_logger.info('Token expiration flag cleared');
}
/// Get current bellDelay value from settings
static double? _getCurrentBellDelay() {
try {
@@ -733,6 +744,11 @@ class LiveActivityService {
searchStartDate: nextWeekStart,
);
if (searchResult.tokenExpired) {
_tokenExpired = true;
_logger.warning('[GlobalSearch] onUserLogin: Token expired during global search');
}
allLessons.addAll(searchResult.allLessons);
_logger.info('[GlobalSearch] onUserLogin: Global searcher returned ${searchResult.allLessons.length} lessons');
@@ -1038,6 +1054,11 @@ class LiveActivityService {
searchStartDate: nextWeekStart,
);
if (searchResult.tokenExpired) {
_tokenExpired = true;
_logger.warning('[GlobalSearch] Token expired during global search');
}
allLessons.addAll(searchResult.allLessons);
_logger.info('[GlobalSearch] Global searcher returned ${searchResult.allLessons.length} lessons');
@@ -1596,6 +1617,7 @@ class LiveActivityService {
List<Lesson> allLessons = [];
Lesson? lastBreakDay;
Lesson? firstSchoolDayAfterBreak;
bool tokenExpired = false;
DateTime currentSearchDate = searchStartDate;
int weeksSearched = 0;
@@ -1650,7 +1672,58 @@ class LiveActivityService {
_logger.info('[GlobalBreakSearcher] Week ${weeksSearched + 1} returned no data, continuing search...');
}
} catch (e) {
_logger.warning('[GlobalBreakSearcher] Error fetching week ${weeksSearched + 1}: $e');
final isTokenError = e.toString().contains('TokenExpiredException') ||
e.toString().contains('InvalidGrantException');
if (isTokenError) {
tokenExpired = true;
_logger.warning('[GlobalBreakSearcher] Token expired during week ${weeksSearched + 1}, falling back to cache');
} else {
_logger.warning('[GlobalBreakSearcher] Error fetching week ${weeksSearched + 1}: $e');
}
try {
final cachedResponse = await client.getTimeTable(weekStart, weekEnd, forceCache: true);
if (cachedResponse.response != null && cachedResponse.response!.isNotEmpty) {
final weekLessons = cachedResponse.response!;
_logger.info('[GlobalBreakSearcher] Loaded ${weekLessons.length} lessons from cache for week ${weeksSearched + 1}');
final breakEvents = weekLessons.where((lesson) {
final uid = lesson.uid.toLowerCase();
final name = lesson.name.toLowerCase();
return uid.contains('tanevrendjeesemeny') &&
!name.contains('tanítási nap') &&
(name.contains('pihenőnap') ||
name.contains('munkaszüneti') ||
name.contains('ünnepnap') ||
name.contains('tanítás nélküli') ||
name.contains('nem órarendi nap'));
}).toList();
final schoolLessons = weekLessons.where((lesson) {
final uid = lesson.uid.toLowerCase();
return uid.contains('orarendiora') || uid.contains('tanitasiora') || uid.contains('uresora');
}).toList();
allLessons.addAll(weekLessons);
if (breakEvents.isNotEmpty) {
breakEvents.sort((a, b) => a.start.compareTo(b.start));
lastBreakDay = breakEvents.last;
_logger.info('[GlobalBreakSearcher] Found ${breakEvents.length} break event(s) in cached week ${weeksSearched + 1}, last: ${lastBreakDay.name} on ${lastBreakDay.date.split('T')[0]}');
} else if (schoolLessons.isNotEmpty) {
schoolLessons.sort((a, b) => a.start.compareTo(b.start));
firstSchoolDayAfterBreak = schoolLessons.first;
_logger.info('[GlobalBreakSearcher] Found first school day after break in cache: ${firstSchoolDayAfterBreak.name} on ${firstSchoolDayAfterBreak.date.split('T')[0]}');
break;
}
} else {
_logger.info('[GlobalBreakSearcher] No cache available for week ${weeksSearched + 1}');
}
} catch (cacheError) {
_logger.warning('[GlobalBreakSearcher] Cache fallback also failed for week ${weeksSearched + 1}: $cacheError');
}
}
currentSearchDate = currentSearchDate.add(const Duration(days: 7));
@@ -1665,12 +1738,13 @@ class LiveActivityService {
_logger.warning('[GlobalBreakSearcher] Reached maximum search limit ($maxWeeks weeks)');
}
_logger.info('[GlobalBreakSearcher] Search completed: ${allLessons.length} lessons found, last break: ${lastBreakDay?.date.split('T')[0] ?? 'none'}, first school day: ${firstSchoolDayAfterBreak?.date.split('T')[0] ?? 'none'}');
_logger.info('[GlobalBreakSearcher] Search completed: ${allLessons.length} lessons found, last break: ${lastBreakDay?.date.split('T')[0] ?? 'none'}, first school day: ${firstSchoolDayAfterBreak?.date.split('T')[0] ?? 'none'}, tokenExpired: $tokenExpired');
return _GlobalSearchResult(
allLessons: allLessons,
lastBreakDay: lastBreakDay,
firstSchoolDayAfterBreak: firstSchoolDayAfterBreak,
tokenExpired: tokenExpired,
);
}
}
@@ -1680,10 +1754,12 @@ class _GlobalSearchResult {
final List<Lesson> allLessons;
final Lesson? lastBreakDay;
final Lesson? firstSchoolDayAfterBreak;
final bool tokenExpired;
_GlobalSearchResult({
required this.allLessons,
this.lastBreakDay,
this.firstSchoolDayAfterBreak,
this.tokenExpired = false,
});
}

View File

@@ -0,0 +1,61 @@
import 'package:firka/helpers/settings.dart';
import 'package:firka/main.dart';
import 'package:firka/ui/model/style.dart';
import 'package:firka/ui/phone/widgets/login_webview.dart';
import 'package:flutter/material.dart';
void showReauthBottomSheet(BuildContext context, AppInitialization data, String message) {
final accountPicker = (data.settings
.group("profile_settings")["e_kreta_account_picker"]
as SettingsKretenAccountPicker);
final currentToken = data.tokens.isNotEmpty && accountPicker.accountIndex < data.tokens.length
? data.tokens[accountPicker.accountIndex]
: null;
final username = currentToken?.studentId;
final schoolId = currentToken?.iss;
showModalBottomSheet(
context: context,
elevation: 100,
isScrollControlled: true,
isDismissible: true,
enableDrag: true,
backgroundColor: Colors.transparent,
builder: (BuildContext context) {
return Container(
decoration: BoxDecoration(
color: appStyle.colors.background,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: appStyle.colors.errorCard,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Text(
message,
style: appStyle.fonts.B_16R.copyWith(
color: appStyle.colors.errorText,
),
textAlign: TextAlign.center,
),
),
Expanded(
child: LoginWebviewWidget(
data,
username: username,
schoolId: schoolId,
),
),
],
),
);
},
);
}

View File

@@ -0,0 +1,90 @@
import 'package:firka/main.dart';
import 'package:firka/ui/model/style.dart';
import 'package:firka/ui/phone/pages/extras/main_reauth.dart';
import 'package:firka/ui/widget/firka_icon.dart';
import 'package:flutter/material.dart';
import 'package:majesticons_flutter/majesticons_flutter.dart';
class ReauthToastWidget extends StatefulWidget {
final AppInitialization data;
final Function() onDismiss;
const ReauthToastWidget({
Key? key,
required this.data,
required this.onDismiss,
}) : super(key: key);
@override
State<ReauthToastWidget> createState() => _ReauthToastWidgetState();
}
class _ReauthToastWidgetState extends State<ReauthToastWidget> {
double _dragOffset = 0;
@override
Widget build(BuildContext context) {
return Positioned(
top: MediaQuery.of(context).size.height / 1.6 + _dragOffset,
left: 0.0,
right: 0.0,
bottom: 0,
child: Center(
child: GestureDetector(
onTap: () {
showReauthBottomSheet(context, widget.data, widget.data.l10n.reauth);
},
onVerticalDragUpdate: (details) {
setState(() {
_dragOffset += details.delta.dy;
if (_dragOffset < 0) _dragOffset = 0;
});
},
onVerticalDragEnd: (details) {
if (_dragOffset > 50) {
widget.onDismiss();
} else {
setState(() {
_dragOffset = 0;
});
}
},
child: Card(
color: appStyle.colors.errorCard,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(200)),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 15, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.data.l10n.reauth,
style: appStyle.fonts.B_16SB
.copyWith(color: appStyle.colors.errorText),
),
SizedBox(width: 8),
FirkaIconWidget(
FirkaIconType.majesticons,
Majesticon.loginSolid,
color: appStyle.colors.errorAccent,
size: 24,
),
],
),
),
),
),
),
);
}
}
Widget buildReauthToast(BuildContext context, AppInitialization data, Function() onDismiss) {
return ReauthToastWidget(
data: data,
onDismiss: onDismiss,
);
}

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:firka/helpers/api/client/kreta_stream.dart';
import 'package:firka/helpers/api/exceptions/token.dart';
import 'package:firka/helpers/extensions.dart';
import 'package:firka/helpers/live_activity_service.dart';
import 'package:firka/helpers/settings.dart';
@@ -9,6 +10,8 @@ import 'package:firka/helpers/update_notifier.dart';
import 'package:firka/main.dart';
import 'package:firka/ui/model/style.dart';
import 'package:firka/ui/phone/pages/extras/main_wear_pair.dart';
import 'package:firka/ui/phone/pages/extras/main_reauth.dart';
import 'package:firka/ui/phone/pages/extras/reauth_toast.dart';
import 'package:firka/ui/phone/pages/home/home_grades.dart';
import 'package:firka/ui/phone/pages/home/home_main.dart';
import 'package:firka/ui/phone/pages/home/home_subpage.dart';
@@ -133,7 +136,29 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
await HomeWidget.updateWidget(
qualifiedAndroidName: "app.firka.naplo.glance.TimetableWidget");
}
if (Platform.isIOS && LiveActivityService.isTokenExpired && !_disposed) {
showReauthBottomSheet(context, widget.data, widget.data.l10n.reauth);
}
} catch (e) {
if (e is TokenExpiredException || e is InvalidGrantException) {
activeToast = ActiveToastType.reauth;
if (_disposed) return;
setState(() {
toast = buildReauthToast(context, widget.data, () {
if (!_disposed) {
setState(() {
activeToast = ActiveToastType.none;
toast = null;
});
}
});
});
return;
}
activeToast = ActiveToastType.error;
var dismissDelay = 120;
@@ -167,7 +192,6 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
padding: EdgeInsets.symmetric(horizontal: 15, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
// Use min to prevent filling the width
children: [
Text(
widget.data.l10n.api_error,