forked from firka/firka
Improve Watch sync, token, and live activity handling
Multiple fixes and improvements for watch <> phone sync, token recovery, and live activity behavior: - WatchSessionManager: add mergeApplicationContext to avoid clobbering app context, add thread-safe pending auth queue and flush, add sendMessageToWatch API, ensure message handling runs on main thread, add reply-timeout logic for language requests, support fire-and-forget messages, and improve enqueue/flush logic. - WatchConnectivityManager & SettingsView: publish shared session state on force-logout/logout and improve account-switch/token handling; clear DataStore error and reset recovery state after token updates. - DataStore & WatchL10n: add recovery-in-progress guard to avoid duplicate recovery runs, reset language version tracking on account switch, make WatchL10n.setLanguage main-thread safe. - TokenManager & SharedKeychainManager: remove old keychain observer plumbing and instead publish shared session state when active token changes or is deleted. - UI tweaks: reduce icon/text sizes and spacing in pairing view; only show sync button when paired; Settings logout now also publishes shared state. - Watch sync wiring in Flutter: replace direct watch_connectivity usage with a MethodChannel-backed WatchSyncHelper.sendMessageToWatch and onWatchMessage callback; main and pairing UI updated accordingly. - Kreta client: replace simple boolean mutex with a Completer-based mutex and timeout handling to avoid busy-waiting. - LiveActivityService: throttle/avoid frequent activity recreation (cache last recreation time), skip placeholder creation when called from background, and minor cache-clearing adjustments. - HomeScreen: add WidgetsBindingObserver to manage lifecycle, prevent prefetch while backgrounded, debounce prefetch, ensure LiveActivity registration runs once and refresh on resume after first prefetch. These changes increase robustness of token sync and account switching, reduce race conditions and duplicate work, and avoid conflicts between WCSession and Flutter plugin delegates.
This commit is contained in:
@@ -72,6 +72,18 @@ struct ContentView: View {
|
||||
guard scenePhase == .active else { return }
|
||||
dataStore.reconcileSharedSessionState()
|
||||
WatchL10n.shared.reconcileFromSharedState()
|
||||
|
||||
if !dataStore.hasToken {
|
||||
dataStore.checkTokenState()
|
||||
if dataStore.hasToken {
|
||||
print("[Watch] Token appeared (iCloud Keychain sync?), refreshing...")
|
||||
Task {
|
||||
await dataStore.refreshAllWithRecovery()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if shouldAutoRefresh && !dataStore.isLoading {
|
||||
print("[Watch] Data became stale (>10 min), auto-refreshing...")
|
||||
Task {
|
||||
@@ -151,21 +163,21 @@ struct PairingView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 50))
|
||||
.font(.system(size: 36))
|
||||
.foregroundColor(.blue)
|
||||
|
||||
Text(titleKey.localized)
|
||||
.font(.headline)
|
||||
|
||||
Text(descriptionKey.localized)
|
||||
.font(.caption)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
if isWatchSystemPaired && WCSession.default.isReachable {
|
||||
if isWatchSystemPaired {
|
||||
Button("sync_button".localized) {
|
||||
onRequestToken?()
|
||||
}
|
||||
|
||||
@@ -74,9 +74,17 @@ class WatchL10n {
|
||||
}
|
||||
|
||||
func setLanguage(_ language: WatchLanguage) {
|
||||
currentLanguage = language
|
||||
loadStrings()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
if Thread.isMainThread {
|
||||
currentLanguage = language
|
||||
loadStrings()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
} else {
|
||||
DispatchQueue.main.async { [self] in
|
||||
currentLanguage = language
|
||||
loadStrings()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateFromiPhone(languageCode: String, sharedStateVersion: Int64? = nil) {
|
||||
@@ -115,6 +123,11 @@ class WatchL10n {
|
||||
UserDefaults.standard.set(value, forKey: lastAppliedSharedLanguageVersionKey)
|
||||
}
|
||||
|
||||
func resetLanguageVersionTracking() {
|
||||
setLastAppliedSharedLanguageVersion(0)
|
||||
print("[WatchL10n] Language version tracking reset for account switch")
|
||||
}
|
||||
|
||||
func reconcileFromSharedState() {
|
||||
guard syncWithiPhone else { return }
|
||||
guard let sharedState = SharedLanguageStateManager.shared.loadState() else { return }
|
||||
|
||||
@@ -106,6 +106,7 @@ class DataStore {
|
||||
lastUpdated = nil
|
||||
error = nil
|
||||
recoveryAttempted = false
|
||||
WatchL10n.shared.resetLanguageVersionTracking()
|
||||
}
|
||||
setLastHandledSessionActiveStudentIdNorm(activeStudentIdNorm)
|
||||
} else {
|
||||
@@ -322,7 +323,16 @@ class DataStore {
|
||||
}
|
||||
}
|
||||
|
||||
private var isRecoveryInProgress: Bool = false
|
||||
|
||||
func refreshAllWithRecovery() async {
|
||||
guard !isRecoveryInProgress && !isLoading else {
|
||||
print("[Watch] refreshAllWithRecovery() already in progress or refreshAll() running, skipping duplicate call")
|
||||
return
|
||||
}
|
||||
isRecoveryInProgress = true
|
||||
defer { isRecoveryInProgress = false }
|
||||
|
||||
reconcileSharedSessionState()
|
||||
WatchL10n.shared.refreshFromiPhoneAndSharedState()
|
||||
|
||||
@@ -334,7 +344,7 @@ class DataStore {
|
||||
|
||||
if shouldRequestTokenFromPhone {
|
||||
WatchConnectivityManager.shared.requestTokenFromPhone()
|
||||
try? await Task.sleep(nanoseconds: 700_000_000)
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
checkTokenState()
|
||||
}
|
||||
|
||||
|
||||
@@ -294,6 +294,10 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
|
||||
|
||||
private func handleForceLogoutFromPhone() {
|
||||
TokenManager.shared.deleteToken()
|
||||
_ = SharedSessionStateManager.shared.publishState(
|
||||
hasAnyAccount: false,
|
||||
activeStudentIdNorm: nil
|
||||
)
|
||||
DataStore.shared.clearAll()
|
||||
DataStore.shared.resetRecoveryState()
|
||||
DataStore.shared.checkTokenState()
|
||||
@@ -400,19 +404,24 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
|
||||
|
||||
let token = try decoder.decode(WatchToken.self, from: jsonData)
|
||||
let currentToken = TokenManager.shared.loadToken()
|
||||
|
||||
let isAccountSwitch = currentToken != nil && !token.isSameAccount(as: currentToken!)
|
||||
let shouldForceAccountSwitch: Bool
|
||||
if incomingSentAtMs > 0,
|
||||
let currentToken,
|
||||
!token.isSameAccount(as: currentToken) {
|
||||
shouldForceAccountSwitch = true
|
||||
if isAccountSwitch {
|
||||
if incomingSentAtMs > 0 {
|
||||
shouldForceAccountSwitch = true
|
||||
} else {
|
||||
shouldForceAccountSwitch = token.isNewer(than: currentToken!)
|
||||
}
|
||||
} else {
|
||||
shouldForceAccountSwitch = false
|
||||
}
|
||||
|
||||
if incomingSentAtMs <= 0,
|
||||
let currentToken,
|
||||
!isAccountSwitch,
|
||||
!token.isNewer(than: currentToken) {
|
||||
print("[Watch] Ignoring stale token_update without sentAtMs")
|
||||
print("[Watch] Ignoring stale token_update without sentAtMs (same account, not newer)")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -432,6 +441,8 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
|
||||
lastAppliedTokenUpdateMs = max(previousSentAtMs, incomingSentAtMs)
|
||||
}
|
||||
|
||||
DataStore.shared.clearError()
|
||||
DataStore.shared.resetRecoveryState()
|
||||
DataStore.shared.checkTokenState()
|
||||
|
||||
Task {
|
||||
|
||||
@@ -69,6 +69,10 @@ struct SettingsView: View {
|
||||
|
||||
private func logout() {
|
||||
TokenManager.shared.deleteToken()
|
||||
_ = SharedSessionStateManager.shared.publishState(
|
||||
hasAnyAccount: false,
|
||||
activeStudentIdNorm: nil
|
||||
)
|
||||
DataStore.shared.clearAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,30 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
private var isFlutterWatchSyncReady = false
|
||||
private var pendingAuthPayloads: [[String: Any]] = []
|
||||
private var pendingICloudRecoveryNotification = false
|
||||
private let pendingAuthQueue = DispatchQueue(label: "app.firka.pendingAuthQueue")
|
||||
|
||||
override private init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
private func mergeApplicationContext(_ newEntries: [String: Any]) {
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated else { return }
|
||||
|
||||
var merged = session.applicationContext
|
||||
for (key, value) in newEntries {
|
||||
merged[key] = value
|
||||
}
|
||||
if !newEntries.keys.contains("force_logout") {
|
||||
merged.removeValue(forKey: "force_logout")
|
||||
}
|
||||
do {
|
||||
try session.updateApplicationContext(merged)
|
||||
} catch {
|
||||
print("[WatchSessionManager] Failed to merge applicationContext: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func setup(with messenger: FlutterBinaryMessenger) {
|
||||
flutterChannel = FlutterMethodChannel(
|
||||
name: "app.firka/watch_sync",
|
||||
@@ -58,6 +77,8 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
self?.handleClearAllRefreshLeases(result: result)
|
||||
case "clearSharedLanguageState":
|
||||
self?.handleClearSharedLanguageState(result: result)
|
||||
case "sendMessageToWatch":
|
||||
self?.handleSendMessageToWatch(arguments: call.arguments, result: result)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
@@ -160,11 +181,13 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
}
|
||||
|
||||
private func enqueuePendingAuth(_ authData: [String: Any]) {
|
||||
if pendingAuthPayloads.contains(where: { sameTokenPayload($0, authData) }) {
|
||||
return
|
||||
pendingAuthQueue.sync {
|
||||
if pendingAuthPayloads.contains(where: { sameTokenPayload($0, authData) }) {
|
||||
return
|
||||
}
|
||||
pendingAuthPayloads.append(authData)
|
||||
print("[WatchSessionManager] Queued pending token from Watch until Flutter sync is ready")
|
||||
}
|
||||
pendingAuthPayloads.append(authData)
|
||||
print("[WatchSessionManager] Queued pending token from Watch until Flutter sync is ready")
|
||||
}
|
||||
|
||||
private func forwardTokenToFlutter(_ authData: [String: Any]) {
|
||||
@@ -188,13 +211,19 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
guard isFlutterWatchSyncReady else {
|
||||
return
|
||||
}
|
||||
if !pendingAuthPayloads.isEmpty {
|
||||
print("[WatchSessionManager] Flushing \(pendingAuthPayloads.count) queued token event(s) to Flutter")
|
||||
|
||||
let payloadsToFlush: [[String: Any]] = pendingAuthQueue.sync {
|
||||
let copy = pendingAuthPayloads
|
||||
pendingAuthPayloads.removeAll()
|
||||
return copy
|
||||
}
|
||||
for authData in pendingAuthPayloads {
|
||||
|
||||
if !payloadsToFlush.isEmpty {
|
||||
print("[WatchSessionManager] Flushing \(payloadsToFlush.count) queued token event(s) to Flutter")
|
||||
}
|
||||
for authData in payloadsToFlush {
|
||||
flutterChannel?.invokeMethod("onTokenFromWatch", arguments: authData)
|
||||
}
|
||||
pendingAuthPayloads.removeAll()
|
||||
|
||||
if pendingICloudRecoveryNotification {
|
||||
pendingICloudRecoveryNotification = false
|
||||
@@ -228,13 +257,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
|
||||
let session = WCSession.default
|
||||
|
||||
do {
|
||||
try session.updateApplicationContext([
|
||||
"auth": authData
|
||||
])
|
||||
} catch {
|
||||
print("[WatchSessionManager] Failed to update applicationContext for token: \(error)")
|
||||
}
|
||||
mergeApplicationContext(["auth": authData])
|
||||
|
||||
session.transferUserInfo([
|
||||
"id": "token_update",
|
||||
@@ -271,13 +294,9 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try WCSession.default.updateApplicationContext(["widget_data": jsonString])
|
||||
result(nil)
|
||||
print("[WatchSessionManager] Widget data sent to Watch")
|
||||
} catch {
|
||||
result(FlutterError(code: "UPDATE_ERROR", message: error.localizedDescription, details: nil))
|
||||
}
|
||||
mergeApplicationContext(["widget_data": jsonString])
|
||||
result(nil)
|
||||
print("[WatchSessionManager] Widget data sent to Watch")
|
||||
}
|
||||
|
||||
private func handleSendLanguageToWatch(arguments: Any?, result: @escaping FlutterResult) {
|
||||
@@ -295,15 +314,11 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
let sharedState = SharedLanguageStateManager.shared.publishState(languageCode: languageCode)
|
||||
|
||||
if WCSession.default.activationState == .activated {
|
||||
do {
|
||||
try WCSession.default.updateApplicationContext([
|
||||
"language": languageCode,
|
||||
"language_state_version": sharedState.stateVersion
|
||||
])
|
||||
print("[WatchSessionManager] Language '\(languageCode)' sent to Watch via applicationContext")
|
||||
} catch {
|
||||
print("[WatchSessionManager] Failed to update applicationContext for language: \(error)")
|
||||
}
|
||||
mergeApplicationContext([
|
||||
"language": languageCode,
|
||||
"language_state_version": sharedState.stateVersion
|
||||
])
|
||||
print("[WatchSessionManager] Language '\(languageCode)' sent to Watch via applicationContext")
|
||||
|
||||
WCSession.default.transferUserInfo([
|
||||
"id": "language_update",
|
||||
@@ -601,11 +616,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try WCSession.default.updateApplicationContext(["force_logout": true])
|
||||
} catch {
|
||||
print("[WatchSessionManager] Failed to update applicationContext for logout: \(error)")
|
||||
}
|
||||
mergeApplicationContext(["force_logout": true])
|
||||
|
||||
WCSession.default.transferUserInfo([
|
||||
"id": "force_logout"
|
||||
@@ -614,6 +625,28 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
result(nil)
|
||||
}
|
||||
|
||||
private func handleSendMessageToWatch(arguments: Any?, result: @escaping FlutterResult) {
|
||||
guard let message = arguments as? [String: Any] else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "Arguments must be a dictionary", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
guard WCSession.default.activationState == .activated else {
|
||||
result(FlutterError(code: "SESSION_NOT_ACTIVE", message: "WCSession is not activated", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
guard WCSession.default.isReachable else {
|
||||
result(FlutterError(code: "NOT_REACHABLE", message: "Watch is not reachable", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
WCSession.default.sendMessage(message, replyHandler: nil, errorHandler: { error in
|
||||
print("[WatchSessionManager] Failed to send message to Watch: \(error.localizedDescription)")
|
||||
})
|
||||
result(nil)
|
||||
}
|
||||
|
||||
func session(
|
||||
_ session: WCSession,
|
||||
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||
@@ -651,6 +684,12 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
didReceiveMessage message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void
|
||||
) {
|
||||
DispatchQueue.main.async { [self] in
|
||||
self._handleMessageWithReply(message: message, replyHandler: replyHandler)
|
||||
}
|
||||
}
|
||||
|
||||
private func _handleMessageWithReply(message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
print("[WatchSessionManager] Received message from Watch: \(message)")
|
||||
|
||||
guard let action = message["action"] as? String else {
|
||||
@@ -670,8 +709,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
}
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.flutterChannel?.invokeMethod("getTokenForWatch", arguments: nil) { result in
|
||||
self.flutterChannel?.invokeMethod("getTokenForWatch", arguments: nil) { result in
|
||||
if let tokenData = result as? [String: Any] {
|
||||
if let error = tokenData["error"] as? String {
|
||||
if error == "needsReauth" {
|
||||
@@ -703,7 +741,6 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "requestLanguage":
|
||||
if !self.isFlutterWatchSyncReady {
|
||||
@@ -734,37 +771,56 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
flutterChannel.invokeMethod("getLanguageForWatch", arguments: nil) { result in
|
||||
if let languageCode = result as? String, !languageCode.isEmpty {
|
||||
if let existingState = SharedLanguageStateManager.shared.loadState(),
|
||||
existingState.languageCode == languageCode {
|
||||
print("[WatchSessionManager] Sending language to Watch from shared cache: \(languageCode)")
|
||||
replyHandler([
|
||||
"language": languageCode,
|
||||
"language_state_version": existingState.stateVersion
|
||||
])
|
||||
return
|
||||
}
|
||||
var hasReplied = false
|
||||
let timeoutWorkItem = DispatchWorkItem {
|
||||
guard !hasReplied else { return }
|
||||
hasReplied = true
|
||||
if let sharedState = SharedLanguageStateManager.shared.loadState() {
|
||||
print("[WatchSessionManager] Flutter timeout, serving shared language: \(sharedState.languageCode)")
|
||||
replyHandler([
|
||||
"language": sharedState.languageCode,
|
||||
"language_state_version": sharedState.stateVersion
|
||||
])
|
||||
} else {
|
||||
print("[WatchSessionManager] Flutter timeout and no shared language available")
|
||||
replyHandler(["error": "timeout"])
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: timeoutWorkItem)
|
||||
|
||||
let sharedState = SharedLanguageStateManager.shared.publishState(
|
||||
languageCode: languageCode
|
||||
)
|
||||
print("[WatchSessionManager] Sending language to Watch: \(languageCode)")
|
||||
flutterChannel.invokeMethod("getLanguageForWatch", arguments: nil) { result in
|
||||
timeoutWorkItem.cancel()
|
||||
guard !hasReplied else { return }
|
||||
hasReplied = true
|
||||
|
||||
if let languageCode = result as? String, !languageCode.isEmpty {
|
||||
if let existingState = SharedLanguageStateManager.shared.loadState(),
|
||||
existingState.languageCode == languageCode {
|
||||
print("[WatchSessionManager] Sending language to Watch from shared cache: \(languageCode)")
|
||||
replyHandler([
|
||||
"language": languageCode,
|
||||
"language_state_version": sharedState.stateVersion
|
||||
"language_state_version": existingState.stateVersion
|
||||
])
|
||||
} else if let sharedState = SharedLanguageStateManager.shared.loadState() {
|
||||
print("[WatchSessionManager] No language from Flutter, serving last shared language: \(sharedState.languageCode)")
|
||||
replyHandler([
|
||||
"language": sharedState.languageCode,
|
||||
"language_state_version": sharedState.stateVersion
|
||||
])
|
||||
} else {
|
||||
print("[WatchSessionManager] No language available from Flutter or shared state")
|
||||
replyHandler(["error": "language_not_ready"])
|
||||
return
|
||||
}
|
||||
|
||||
let sharedState = SharedLanguageStateManager.shared.publishState(
|
||||
languageCode: languageCode
|
||||
)
|
||||
print("[WatchSessionManager] Sending language to Watch: \(languageCode)")
|
||||
replyHandler([
|
||||
"language": languageCode,
|
||||
"language_state_version": sharedState.stateVersion
|
||||
])
|
||||
} else if let sharedState = SharedLanguageStateManager.shared.loadState() {
|
||||
print("[WatchSessionManager] No language from Flutter, serving last shared language: \(sharedState.languageCode)")
|
||||
replyHandler([
|
||||
"language": sharedState.languageCode,
|
||||
"language_state_version": sharedState.stateVersion
|
||||
])
|
||||
} else {
|
||||
print("[WatchSessionManager] No language available from Flutter or shared state")
|
||||
replyHandler(["error": "language_not_ready"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -776,27 +832,23 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
|
||||
if !self.isFlutterWatchSyncReady {
|
||||
print("[WatchSessionManager] Flutter not ready, queueing token from Watch")
|
||||
DispatchQueue.main.async {
|
||||
self.enqueuePendingAuth(tokenData)
|
||||
}
|
||||
self.enqueuePendingAuth(tokenData)
|
||||
replyHandler(["success": true])
|
||||
return
|
||||
}
|
||||
|
||||
print("[WatchSessionManager] Receiving token from Watch")
|
||||
DispatchQueue.main.async {
|
||||
self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: tokenData) { result in
|
||||
if let success = result as? Bool, success {
|
||||
print("[WatchSessionManager] Flutter accepted Watch token")
|
||||
replyHandler(["success": true])
|
||||
} else if let resultDict = result as? [String: Any],
|
||||
let success = resultDict["success"] as? Bool, success {
|
||||
print("[WatchSessionManager] Flutter accepted Watch token")
|
||||
replyHandler(["success": true])
|
||||
} else {
|
||||
print("[WatchSessionManager] Flutter rejected Watch token")
|
||||
replyHandler(["error": "rejected"])
|
||||
}
|
||||
self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: tokenData) { result in
|
||||
if let success = result as? Bool, success {
|
||||
print("[WatchSessionManager] Flutter accepted Watch token")
|
||||
replyHandler(["success": true])
|
||||
} else if let resultDict = result as? [String: Any],
|
||||
let success = resultDict["success"] as? Bool, success {
|
||||
print("[WatchSessionManager] Flutter accepted Watch token")
|
||||
replyHandler(["success": true])
|
||||
} else {
|
||||
print("[WatchSessionManager] Flutter rejected Watch token")
|
||||
replyHandler(["error": "rejected"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,6 +857,13 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
print("[WatchSessionManager] Received fire-and-forget message from Watch: \(message)")
|
||||
DispatchQueue.main.async {
|
||||
self.flutterChannel?.invokeMethod("onWatchMessage", arguments: message)
|
||||
}
|
||||
}
|
||||
|
||||
func sessionDidBecomeInactive(_ session: WCSession) {
|
||||
print("[WatchSessionManager] Session did become inactive")
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ class SharedKeychainManager {
|
||||
private let deviceName = "Watch"
|
||||
#endif
|
||||
|
||||
private var changeObserver: ((WatchToken) -> Void)?
|
||||
|
||||
private init() {}
|
||||
|
||||
var resolvedAccessGroup: String {
|
||||
@@ -201,15 +199,6 @@ class SharedKeychainManager {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Observer (for compatibility with old iCloudTokenManager interface)
|
||||
func observeChanges(_ observer: @escaping (WatchToken) -> Void) {
|
||||
self.changeObserver = observer
|
||||
}
|
||||
|
||||
func notifyObservers(with token: WatchToken) {
|
||||
changeObserver?(token)
|
||||
}
|
||||
|
||||
// MARK: - Migration from KV Store
|
||||
func migrateFromKVStoreAndClear() -> WatchToken? {
|
||||
let iCloudStore = NSUbiquitousKeyValueStore.default
|
||||
|
||||
@@ -186,61 +186,6 @@ class TokenManager {
|
||||
|
||||
private init() {
|
||||
runKVStoreMigrationIfNeeded()
|
||||
|
||||
SharedKeychainManager.shared.observeChanges { [weak self] sharedToken in
|
||||
guard let self = self else { return }
|
||||
|
||||
let preferredStudentIdNorm = self.getActiveStudentIdNorm()
|
||||
let isValidToken = sharedToken.expiryDate > Date().addingTimeInterval(60)
|
||||
let preferredLocalToken = self.localTokenFromKeychainAndFile(
|
||||
preferredStudentIdNorm: preferredStudentIdNorm
|
||||
)
|
||||
|
||||
if let preferredStudentIdNorm,
|
||||
sharedToken.studentIdNorm != preferredStudentIdNorm,
|
||||
preferredLocalToken != nil {
|
||||
print("[TokenManager] Ignoring shared Keychain token for inactive account (\(sharedToken.studentIdNorm)), active is \(preferredStudentIdNorm)")
|
||||
return
|
||||
}
|
||||
|
||||
let localToken = preferredLocalToken ?? self.localTokenFromKeychainAndFile()
|
||||
|
||||
if let localToken = localToken {
|
||||
if sharedToken.isNewer(than: localToken) {
|
||||
print("[TokenManager] Shared Keychain token is fresher, updating local cache")
|
||||
try? self.saveTokenToKeychain(sharedToken)
|
||||
try? self.saveTokenToFile(sharedToken)
|
||||
self.setActiveStudentIdNorm(sharedToken.studentIdNorm)
|
||||
|
||||
#if os(watchOS)
|
||||
DataStore.shared.checkTokenState()
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
if isValidToken {
|
||||
self.notifyiOSTokenRecovered()
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
print("[TokenManager] Local token is fresher or equal, ignoring shared Keychain update")
|
||||
}
|
||||
} else {
|
||||
print("[TokenManager] No local token, using shared Keychain token")
|
||||
try? self.saveTokenToKeychain(sharedToken)
|
||||
try? self.saveTokenToFile(sharedToken)
|
||||
self.setActiveStudentIdNorm(sharedToken.studentIdNorm)
|
||||
|
||||
#if os(watchOS)
|
||||
DataStore.shared.checkTokenState()
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
if isValidToken {
|
||||
self.notifyiOSTokenRecovered()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let kvStoreMigrationKey = "firka_kv_store_migrated_v1"
|
||||
@@ -350,8 +295,19 @@ class TokenManager {
|
||||
candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest
|
||||
}
|
||||
}
|
||||
let previousActiveStudentIdNorm = getActiveStudentIdNorm()
|
||||
setActiveStudentIdNorm(freshest.token.studentIdNorm)
|
||||
|
||||
#if os(iOS)
|
||||
if previousActiveStudentIdNorm != freshest.token.studentIdNorm {
|
||||
_ = SharedSessionStateManager.shared.publishState(
|
||||
hasAnyAccount: true,
|
||||
activeStudentIdNorm: freshest.token.studentIdNorm
|
||||
)
|
||||
print("[TokenManager] Active account changed from \(previousActiveStudentIdNorm ?? 0) to \(freshest.token.studentIdNorm), published to SharedSessionState")
|
||||
}
|
||||
#endif
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
formatter.timeZone = TimeZone.current
|
||||
@@ -393,6 +349,9 @@ class TokenManager {
|
||||
// MARK: - Delete Token
|
||||
func deleteToken() {
|
||||
print("[TokenManager] Deleting token from all storage locations")
|
||||
|
||||
SharedSessionStateManager.shared.publishState(hasAnyAccount: false, activeStudentIdNorm: nil)
|
||||
|
||||
if let previousToken = loadToken() {
|
||||
RefreshLeaseManager.shared.clearLeases(studentIdNorm: previousToken.studentIdNorm)
|
||||
} else {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
@@ -56,7 +57,7 @@ class ApiResponse<T> {
|
||||
}
|
||||
|
||||
class KretaClient {
|
||||
bool _tokenMutex = false;
|
||||
Completer<void>? _tokenMutexCompleter;
|
||||
TokenModel model;
|
||||
Isar isar;
|
||||
|
||||
@@ -328,23 +329,28 @@ class KretaClient {
|
||||
|
||||
Future<T> _mutexCallback<T>(Future<T> Function() callback) async {
|
||||
const maxWaitTime = Duration(seconds: 30);
|
||||
final startTime = DateTime.now();
|
||||
|
||||
while (_tokenMutex) {
|
||||
if (DateTime.now().difference(startTime) > maxWaitTime) {
|
||||
logger.warning(
|
||||
"[Mutex] Timeout waiting for token mutex, forcing release",
|
||||
);
|
||||
_tokenMutex = false;
|
||||
break;
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
if (_tokenMutexCompleter != null) {
|
||||
try {
|
||||
await _tokenMutexCompleter!.future.timeout(maxWaitTime, onTimeout: () {
|
||||
logger.warning(
|
||||
"[Mutex] Timeout waiting for token mutex, forcing release");
|
||||
if (_tokenMutexCompleter != null && !_tokenMutexCompleter!.isCompleted) {
|
||||
_tokenMutexCompleter!.complete();
|
||||
}
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
_tokenMutex = true;
|
||||
|
||||
_tokenMutexCompleter = Completer<void>();
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
_tokenMutex = false;
|
||||
final completer = _tokenMutexCompleter;
|
||||
_tokenMutexCompleter = null;
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ class LiveActivityService {
|
||||
static Timer? _updateTimer;
|
||||
static String? _cachedDeviceToken;
|
||||
static bool _isInitialized = false;
|
||||
static DateTime? _lastActivityRecreation;
|
||||
|
||||
static Timer? _bellDelayDebounceTimer;
|
||||
static double? _pendingBellDelay;
|
||||
@@ -863,6 +864,7 @@ class LiveActivityService {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
final liveActivityEnabled = await isEnabled(settingsStore, client);
|
||||
final morningNotificationEnabled =
|
||||
_getCurrentMorningNotificationEnabled() ?? false;
|
||||
@@ -1145,6 +1147,7 @@ class LiveActivityService {
|
||||
|
||||
if (liveActivityEnabled) {
|
||||
await _startPlaceholderActivity(allLessons, studentName);
|
||||
_lastActivityRecreation = DateTime.now();
|
||||
}
|
||||
|
||||
await _startTimetableMonitoring(
|
||||
@@ -1165,7 +1168,6 @@ class LiveActivityService {
|
||||
}
|
||||
|
||||
/// Called when app is opened - sends timetable to backend, backend handles updates
|
||||
/// IMPORTANT: Recreates Live Activity on every app open to refresh the 8-hour push token
|
||||
static Future<void> onAppOpened({
|
||||
required KretaClient client,
|
||||
required String studentName,
|
||||
@@ -1181,6 +1183,20 @@ class LiveActivityService {
|
||||
return;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
if (_lastActivityRecreation != null) {
|
||||
final timeSinceLastRecreation = now.difference(_lastActivityRecreation!);
|
||||
if (timeSinceLastRecreation < const Duration(minutes: 5)) {
|
||||
_logger.info('onAppOpened: Skipping activity recreation, last was ${timeSinceLastRecreation.inSeconds}s ago');
|
||||
await checkAndUpdateTimetable(
|
||||
client: client,
|
||||
studentName: studentName,
|
||||
settingsStore: settingsStore
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final activeActivities = await LiveActivityManager.getActiveActivities();
|
||||
if (activeActivities.isNotEmpty) {
|
||||
_logger.info(
|
||||
@@ -1190,7 +1206,6 @@ class LiveActivityService {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final todayStart = DateTime(now.year, now.month, now.day);
|
||||
final startOfWeek = todayStart.subtract(Duration(days: now.weekday - 1));
|
||||
final endOfWeek = startOfWeek.add(const Duration(days: 6));
|
||||
@@ -1201,6 +1216,7 @@ class LiveActivityService {
|
||||
final allLessons = timetableResponse.response ?? [];
|
||||
|
||||
await _startPlaceholderActivity(allLessons, studentName);
|
||||
_lastActivityRecreation = now;
|
||||
|
||||
_logger.info('New activity created with fresh push token');
|
||||
|
||||
@@ -1585,8 +1601,16 @@ class LiveActivityService {
|
||||
/// Starts a minimal placeholder activity shell - backend will update with real data
|
||||
static Future<void> _startPlaceholderActivity(
|
||||
List<Lesson> allLessons,
|
||||
String studentName,
|
||||
) async {
|
||||
String studentName, {
|
||||
bool isBackground = false,
|
||||
}) async {
|
||||
if (isBackground) {
|
||||
_logger.info(
|
||||
'_startPlaceholderActivity: Called from background context, skipping to preserve existing activity',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always end existing activities to ensure fresh token (8-hour expiration)
|
||||
final activeActivities = await LiveActivityManager.getActiveActivities();
|
||||
if (activeActivities.isNotEmpty) {
|
||||
@@ -1704,10 +1728,8 @@ class LiveActivityService {
|
||||
|
||||
static Future<void> _clearCache() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_deviceTokenKey);
|
||||
await prefs.remove(_lastTimetableUpdateKey);
|
||||
await prefs.remove(_isRegisteredKey);
|
||||
_cachedDeviceToken = null;
|
||||
}
|
||||
|
||||
/// Try to get cached token or wait a short period until iOS provides it
|
||||
|
||||
@@ -468,11 +468,37 @@ class WatchSyncHelper {
|
||||
);
|
||||
await _handleTokenRecoveredFromiCloud();
|
||||
return null;
|
||||
case 'onWatchMessage':
|
||||
_handleWatchMessage(call.arguments);
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback for Watch pairing message events.
|
||||
/// Set by main.dart to handle "ping" messages for Watch pairing flow.
|
||||
static void Function(Map<String, dynamic> message)? onWatchMessage;
|
||||
|
||||
static void _handleWatchMessage(dynamic arguments) {
|
||||
if (arguments == null) return;
|
||||
try {
|
||||
final Map<String, dynamic> message;
|
||||
if (arguments is Map<String, dynamic>) {
|
||||
message = arguments;
|
||||
} else if (arguments is Map) {
|
||||
message = Map<String, dynamic>.from(arguments);
|
||||
} else {
|
||||
debugPrint('[WatchSync] onWatchMessage: unexpected type ${arguments.runtimeType}');
|
||||
return;
|
||||
}
|
||||
debugPrint('[WatchSync] Received Watch message: ${message["id"]}');
|
||||
onWatchMessage?.call(message);
|
||||
} catch (e) {
|
||||
debugPrint('[WatchSync] Error handling Watch message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when iOS receives a fresh token from iCloud (e.g., Watch refreshed)
|
||||
/// This clears the reauth flag if it was set, since we now have a valid token
|
||||
static Future<void> _handleTokenRecoveredFromiCloud() async {
|
||||
@@ -551,6 +577,13 @@ class WatchSyncHelper {
|
||||
return tokenData;
|
||||
}
|
||||
|
||||
/// Send a fire-and-forget message to Watch via WatchSessionManager.
|
||||
/// Replaces direct watch_connectivity plugin usage to avoid WCSession delegate conflict.
|
||||
static Future<void> sendMessageToWatch(Map<String, dynamic> message) async {
|
||||
if (!Platform.isIOS) return;
|
||||
await _invokeMethodWithTimeout('sendMessageToWatch', message);
|
||||
}
|
||||
|
||||
static Future<void> sendTokenToWatch() async {
|
||||
if (!Platform.isIOS) return;
|
||||
final watchInstalled = await isWatchAppInstalled();
|
||||
|
||||
@@ -29,7 +29,6 @@ import 'package:logging/logging.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:watch_connectivity/watch_connectivity.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'helpers/db/models/homework_cache_model.dart';
|
||||
import 'helpers/update_notifier.dart';
|
||||
@@ -566,33 +565,30 @@ class InitializationScreen extends StatelessWidget {
|
||||
}());
|
||||
}
|
||||
|
||||
var watch = WatchConnectivity();
|
||||
|
||||
if (!initData.hasWatchListener) {
|
||||
initData.hasWatchListener = true;
|
||||
|
||||
watch.messageStream.listen((e) {
|
||||
var msg = e.entries.toMap();
|
||||
|
||||
WatchSyncHelper.onWatchMessage = (msg) {
|
||||
logger.finest("WatchOS IPC [Watch -> Phone]: ${msg["id"]}");
|
||||
|
||||
switch (msg["id"]) {
|
||||
case "ping":
|
||||
if (initData.tokens.isNotEmpty) {
|
||||
logger.finest("WatchOS IPC [Phone -> Watch]: pong");
|
||||
watch.sendMessage({"id": "pong"});
|
||||
const watchChannel = MethodChannel('app.firka/watch_sync');
|
||||
watchChannel.invokeMethod('sendMessageToWatch', {"id": "pong"});
|
||||
navigatorKey.currentState?.push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => HomeScreen(
|
||||
initData,
|
||||
true,
|
||||
model: msg["model"] as String,
|
||||
model: msg["model"] as String? ?? "unknown",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
if (snapshot.data!.tokens.isEmpty) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:firka/helpers/debug_helper.dart';
|
||||
import 'package:firka/helpers/ui/firka_card.dart';
|
||||
import 'package:firka/helpers/watch_sync_helper.dart';
|
||||
import 'package:firka/main.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:watch_connectivity/watch_connectivity.dart';
|
||||
|
||||
import '../../../model/style.dart';
|
||||
|
||||
@@ -12,7 +12,6 @@ void showWearBottomSheet(
|
||||
AppInitialization data,
|
||||
String model,
|
||||
) async {
|
||||
final watch = WatchConnectivity();
|
||||
final timetable = await data.client.getTimeTable(
|
||||
timeNow(),
|
||||
timeNow().add(Duration(days: 7)),
|
||||
@@ -108,9 +107,8 @@ void showWearBottomSheet(
|
||||
color: appStyle.colors.accent,
|
||||
),
|
||||
onTap: () {
|
||||
watch.sendMessage({
|
||||
WatchSyncHelper.sendMessageToWatch({
|
||||
"id": "init_data",
|
||||
// "timetable": timetableArray,
|
||||
"auth": {
|
||||
"studentId": data.client.model.studentId,
|
||||
"studentIdNorm": data.client.model.studentIdNorm,
|
||||
|
||||
@@ -84,7 +84,8 @@ UpdateNotifier subPageBack = UpdateNotifier();
|
||||
HomePage homeScreenPage = HomePage.home;
|
||||
List<HomePage> previousPages = List.empty(growable: true);
|
||||
|
||||
class _HomeScreenState extends FirkaState<HomeScreen> {
|
||||
class _HomeScreenState extends FirkaState<HomeScreen>
|
||||
with WidgetsBindingObserver {
|
||||
_HomeScreenState();
|
||||
|
||||
final PageController _pageController = PageController();
|
||||
@@ -267,9 +268,20 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
bool _prefetchInProgress = false;
|
||||
bool _didRunLiveActivityLogin = false;
|
||||
|
||||
void prefetch() async {
|
||||
if (_prefetched) return;
|
||||
if (_prefetchInProgress) return;
|
||||
|
||||
final lifecycle = WidgetsBinding.instance.lifecycleState;
|
||||
if (lifecycle != null && lifecycle != AppLifecycleState.resumed) {
|
||||
logger.info('[Home] prefetch: App is in background, deferring to foreground');
|
||||
return;
|
||||
}
|
||||
|
||||
_prefetchInProgress = true;
|
||||
try {
|
||||
_prefetched = true;
|
||||
|
||||
@@ -302,18 +314,21 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
|
||||
widget.data.settings,
|
||||
);
|
||||
|
||||
final token = pickActiveToken(
|
||||
tokens: widget.data.tokens,
|
||||
settings: widget.data.settings,
|
||||
);
|
||||
final studentName = token?.studentId ?? "Student";
|
||||
LiveActivityService.onUserLogin(
|
||||
client: widget.data.client,
|
||||
studentName: studentName,
|
||||
settingsStore: widget.data.settings,
|
||||
).catchError((e, st) {
|
||||
logger.severe('LiveActivity registration failed: $e', e, st);
|
||||
});
|
||||
if (!_didRunLiveActivityLogin) {
|
||||
_didRunLiveActivityLogin = true;
|
||||
final token = pickActiveToken(
|
||||
tokens: widget.data.tokens,
|
||||
settings: widget.data.settings,
|
||||
);
|
||||
final studentName = token?.studentId ?? "Student";
|
||||
LiveActivityService.onUserLogin(
|
||||
client: widget.data.client,
|
||||
studentName: studentName,
|
||||
settingsStore: widget.data.settings,
|
||||
).catchError((e, st) {
|
||||
logger.severe('LiveActivity registration failed: $e', e, st);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!_disposed &&
|
||||
@@ -413,6 +428,8 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
_prefetchInProgress = false;
|
||||
_hasCompletedFirstPrefetch = true;
|
||||
if (!_disposed) {
|
||||
setState(() {
|
||||
_fetching = false;
|
||||
@@ -490,11 +507,11 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
widget.data.settingsUpdateNotifier.addListener(settingsUpdateListener);
|
||||
|
||||
widget.data.profilePictureUpdateNotifier.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
widget.data.profilePictureUpdateNotifier.addListener(_onProfilePictureUpdated);
|
||||
|
||||
// Listen for reauth state changes (e.g., when Watch sends a valid token)
|
||||
KretaClient.reauthStateNotifier.addListener(_onReauthStateChanged);
|
||||
@@ -529,6 +546,10 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _onProfilePictureUpdated() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _preloadImages() async {
|
||||
final imagePaths = widget.data.settings.appIcons.keys
|
||||
.map((icon) => "assets/images/icons/$icon.webp")
|
||||
@@ -601,7 +622,11 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
|
||||
|
||||
if (widget.watchPair && !pairingDone) {
|
||||
Timer.run(() {
|
||||
showWearBottomSheet(context, widget.data, widget.model!);
|
||||
showWearBottomSheet(
|
||||
context,
|
||||
widget.data,
|
||||
widget.model ?? "unknown",
|
||||
);
|
||||
|
||||
// pairingDone = true;
|
||||
});
|
||||
@@ -892,17 +917,51 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
if (state == AppLifecycleState.resumed && !_disposed) {
|
||||
logger.info('[Home] App resumed to foreground, re-running prefetch');
|
||||
_prefetched = false;
|
||||
_didRunSecondaryICloudRecovery = false;
|
||||
prefetch();
|
||||
|
||||
if (Platform.isIOS) {
|
||||
_refreshLiveActivityOnResume();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasCompletedFirstPrefetch = false;
|
||||
|
||||
void _refreshLiveActivityOnResume() async {
|
||||
if (!_hasCompletedFirstPrefetch) {
|
||||
logger.info('[Home] Skipping onAppOpened: first prefetch not yet complete');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final token = pickActiveToken(
|
||||
tokens: widget.data.tokens,
|
||||
settings: widget.data.settings,
|
||||
);
|
||||
final studentName = token?.studentId ?? "Student";
|
||||
await LiveActivityService.onAppOpened(
|
||||
client: widget.data.client,
|
||||
studentName: studentName,
|
||||
settingsStore: widget.data.settings,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.warning('[Home] LiveActivity refresh on resume failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_pageController.dispose();
|
||||
widget.data.settingsUpdateNotifier.removeListener(settingsUpdateListener);
|
||||
widget.data.profilePictureUpdateNotifier.removeListener(
|
||||
settingsUpdateListener,
|
||||
);
|
||||
|
||||
widget.data.profilePictureUpdateNotifier.removeListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
widget.data.profilePictureUpdateNotifier
|
||||
.removeListener(_onProfilePictureUpdated);
|
||||
|
||||
// Remove reauth state listener
|
||||
KretaClient.reauthStateNotifier.removeListener(_onReauthStateChanged);
|
||||
|
||||
Reference in New Issue
Block a user