forked from firka/firka
Implement token expiration handling and reauthentication UI; add reauth toast and update seasonal icon logic
This commit is contained in:
@@ -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
|
||||
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
61
firka/lib/ui/phone/pages/extras/main_reauth.dart
Normal file
61
firka/lib/ui/phone/pages/extras/main_reauth.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
90
firka/lib/ui/phone/pages/extras/reauth_toast.dart
Normal file
90
firka/lib/ui/phone/pages/extras/reauth_toast.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user