From c16cbdb1866bc91037f8ce98254b3040ea611a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20Gergely?= Date: Wed, 11 Feb 2026 20:42:15 +0100 Subject: [PATCH] Add robust Apple Watch token sync & force-logout Improve Apple Watch sync resilience and token safety across iOS and Flutter. Key changes: - Watch (watchOS): rate-limit phone token requests, handle "force_logout" via applicationContext/userInfo and perform local token deletion/cleanup. Added cooldown tracking. - iPhone host (Swift): expose Flutter methods to check watch app installation, clear iCloud token, and send force-logout to the watch; refuse to forward expired tokens and avoid using iCloud fallback when Flutter reports reauth needed. - Flutter (WatchSyncHelper/KretaClient): cache and check whether a paired Watch app is installed before touching iCloud/watch; reject expired access tokens (both incoming and outgoing) and prevent sending expired tokens to watch; added fresh-install cleanup to clear iCloud/local state once per install; added methods to notify watch of forced logout and to clear iCloud token. - Initialization: run fresh-install cleanup before attempting iCloud recovery and skip recovery if cleanup ran. - Login/settings/home UI: only attempt watch sync when a watch is installed; clear iCloud token on account removal (iOS); minor UX/timing and formatting cleanups. These changes prevent propagation of expired tokens, reduce redundant phone/watch messaging, and provide a controlled force-logout flow for account removal or fresh installs. --- .../Services/WatchConnectivityManager.swift | 26 ++ firka/ios/Runner/WatchSessionManager.swift | 72 +++++- .../lib/helpers/api/client/kreta_client.dart | 8 + firka/lib/helpers/watch_sync_helper.dart | 141 ++++++++++- firka/lib/main.dart | 18 +- .../ui/phone/screens/home/home_screen.dart | 2 +- .../screens/settings/settings_screen.dart | 228 ++++++++++-------- firka/lib/ui/phone/widgets/login_webview.dart | 18 +- 8 files changed, 397 insertions(+), 116 deletions(-) diff --git a/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift b/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift index dce2f533..d59681ba 100644 --- a/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift +++ b/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift @@ -4,6 +4,8 @@ import WatchConnectivity class WatchConnectivityManager: NSObject, WCSessionDelegate { static let shared = WatchConnectivityManager() private let lastAppliedTokenUpdateKey = "watch_last_applied_token_update_ms" + private let minPhoneTokenRequestInterval: TimeInterval = 5 + private var lastPhoneTokenRequestAt: Date? private override init() { super.init() @@ -179,6 +181,14 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { return } + let now = Date() + if let lastPhoneTokenRequestAt, + now.timeIntervalSince(lastPhoneTokenRequestAt) < minPhoneTokenRequestInterval { + print("[Watch] Skipping token request due to cooldown") + return + } + lastPhoneTokenRequestAt = now + print("[Watch] Requesting token from iPhone...") WCSession.default.sendMessage( @@ -201,6 +211,12 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { } private func processApplicationContext(_ context: [String: Any]) { + if (context["force_logout"] as? Bool) == true { + print("[Watch] Received force_logout via applicationContext") + handleForceLogoutFromPhone() + return + } + if let authDict = context["auth"] as? [String: Any] { print("[Watch] Received auth from iPhone") processAuthData(authDict) @@ -228,12 +244,22 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate { case "reauth_required": print("[Watch] Received reauth_required notification from iPhone") DataStore.shared.setReauthRequired() + case "force_logout": + print("[Watch] Received force_logout notification from iPhone") + handleForceLogoutFromPhone() default: break } } } + private func handleForceLogoutFromPhone() { + TokenManager.shared.deleteToken() + DataStore.shared.clearAll() + DataStore.shared.resetRecoveryState() + DataStore.shared.checkTokenState() + } + func sendTokenToiPhoneInBackground() { guard WCSession.default.activationState == .activated else { print("[Watch] Cannot send token: session not activated") diff --git a/firka/ios/Runner/WatchSessionManager.swift b/firka/ios/Runner/WatchSessionManager.swift index 6f120307..85b600b7 100644 --- a/firka/ios/Runner/WatchSessionManager.swift +++ b/firka/ios/Runner/WatchSessionManager.swift @@ -36,6 +36,12 @@ class WatchSessionManager: NSObject, WCSessionDelegate { self?.handleCheckiCloudToken(result: result) case "saveTokeToniCloud": self?.handleSaveTokenToiCloud(arguments: call.arguments, result: result) + case "isWatchAppInstalled": + self?.handleIsWatchAppInstalled(result: result) + case "clearICloudToken": + self?.handleClearICloudToken(result: result) + case "sendLogoutToWatch": + self?.handleSendLogoutToWatch(result: result) case "watchSyncReady": self?.handleWatchSyncReady(result: result) default: @@ -101,10 +107,18 @@ class WatchSessionManager: NSObject, WCSessionDelegate { return tokenData } + private func isTokenUsable(_ token: WatchToken, skewSeconds: TimeInterval = 60) -> Bool { + token.expiryDate > Date().addingTimeInterval(skewSeconds) + } + private func fallbackTokenFromiCloud() -> [String: Any]? { guard let token = iCloudTokenManager.shared.loadToken() else { return nil } + guard isTokenUsable(token, skewSeconds: 0) else { + print("[WatchSessionManager] iCloud fallback token is expired, skipping fallback") + return nil + } return tokenPayload(from: token) } @@ -116,6 +130,14 @@ class WatchSessionManager: NSObject, WCSessionDelegate { (lhs["refreshToken"] as? String) == (rhs["refreshToken"] as? String) } + private func tokenPayloadIsUsable(_ tokenData: [String: Any], skewMs: Int64 = 0) -> Bool { + guard let expiryMs = parseInt64(tokenData["expiryDate"]) else { + return false + } + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + return expiryMs > (nowMs + skewMs) + } + private func enqueuePendingAuth(_ authData: [String: Any]) { if pendingAuthPayloads.contains(where: { sameTokenPayload($0, authData) }) { return @@ -332,6 +354,46 @@ class WatchSessionManager: NSObject, WCSessionDelegate { result(nil) } + private func handleIsWatchAppInstalled(result: @escaping FlutterResult) { + guard WCSession.isSupported() else { + result(false) + return + } + + let session = WCSession.default + let installed = session.isPaired && session.isWatchAppInstalled + result(installed) + } + + private func handleClearICloudToken(result: @escaping FlutterResult) { + iCloudTokenManager.shared.deleteToken() + result(nil) + } + + private func handleSendLogoutToWatch(result: @escaping FlutterResult) { + guard WCSession.default.activationState == .activated else { + result(nil) + return + } + + guard WCSession.default.isWatchAppInstalled else { + result(nil) + return + } + + do { + try WCSession.default.updateApplicationContext(["force_logout": true]) + } catch { + print("[WatchSessionManager] Failed to update applicationContext for logout: \(error)") + } + + WCSession.default.transferUserInfo([ + "id": "force_logout" + ]) + print("[WatchSessionManager] Force logout sent to Watch") + result(nil) + } + func session( _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, @@ -392,7 +454,10 @@ class WatchSessionManager: NSObject, WCSessionDelegate { self.flutterChannel?.invokeMethod("getTokenForWatch", arguments: nil) { result in if let tokenData = result as? [String: Any] { if let error = tokenData["error"] as? String { - if let fallbackToken = self.fallbackTokenFromiCloud() { + if error == "needsReauth" { + print("[WatchSessionManager] Flutter reported needsReauth, not using iCloud fallback") + replyHandler(["error": error]) + } else if let fallbackToken = self.fallbackTokenFromiCloud() { print("[WatchSessionManager] Flutter returned error (\(error)), falling back to iCloud token") replyHandler(["auth": fallbackToken]) } else { @@ -400,6 +465,11 @@ class WatchSessionManager: NSObject, WCSessionDelegate { replyHandler(["error": error]) } } else { + guard self.tokenPayloadIsUsable(tokenData) else { + print("[WatchSessionManager] Flutter token is expired, refusing to send to Watch") + replyHandler(["error": "needsReauth"]) + return + } print("[WatchSessionManager] Sending token to Watch") replyHandler(["auth": tokenData]) } diff --git a/firka/lib/helpers/api/client/kreta_client.dart b/firka/lib/helpers/api/client/kreta_client.dart index e83403fb..30db7b55 100644 --- a/firka/lib/helpers/api/client/kreta_client.dart +++ b/firka/lib/helpers/api/client/kreta_client.dart @@ -92,6 +92,13 @@ class KretaClient { return; } + final watchInstalled = await WatchSyncHelper.isWatchAppInstalled(); + if (!watchInstalled) { + debugPrint( + '[KretaClient] Skipping Apple token sync because no paired Watch app is installed'); + return; + } + try { await WatchSyncHelper.saveTokenToiCloud(token); } catch (e) { @@ -194,6 +201,7 @@ class KretaClient { isar: isar, tokens: initData.tokens, client: this, + allowExpiredAccessToken: true, ); if (recovered) { diff --git a/firka/lib/helpers/watch_sync_helper.dart b/firka/lib/helpers/watch_sync_helper.dart index 76b82705..2d78a5b0 100644 --- a/firka/lib/helpers/watch_sync_helper.dart +++ b/firka/lib/helpers/watch_sync_helper.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:isar/isar.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../main.dart'; import 'active_account_helper.dart'; @@ -15,6 +16,12 @@ import 'db/models/token_model.dart'; class WatchSyncHelper { static const _watchChannel = MethodChannel('app.firka/watch_sync'); static bool _initialized = false; + static bool _watchAppInstalledCache = false; + static DateTime? _lastWatchInstallCheckAt; + static const Duration _watchInstallCheckCooldown = Duration(seconds: 10); + static const Duration _tokenUsableSkew = Duration(seconds: 60); + static const String _iosFreshInstallHandledKey = + 'ios_fresh_install_cleanup_done_v1'; /// Invoke method with timeout to prevent infinite blocking static Future _invokeMethodWithTimeout( @@ -181,6 +188,14 @@ class WatchSyncHelper { return false; } + static bool _isAccessTokenUsable( + DateTime? expiryDate, { + Duration skew = _tokenUsableSkew, + }) { + if (expiryDate == null) return false; + return expiryDate.isAfter(DateTime.now().add(skew)); + } + static Map _buildTokenSyncPayload( TokenModel token, { bool includeSentAt = false, @@ -221,6 +236,79 @@ class WatchSyncHelper { )); } + static Future isWatchAppInstalled({bool forceRefresh = false}) async { + if (!Platform.isIOS) return false; + if (_watchAppInstalledCache && !forceRefresh) return true; + + final now = DateTime.now(); + if (!forceRefresh && + _lastWatchInstallCheckAt != null && + now.difference(_lastWatchInstallCheckAt!) < + _watchInstallCheckCooldown) { + return _watchAppInstalledCache; + } + _lastWatchInstallCheckAt = now; + + final result = await _invokeMethodWithTimeout( + 'isWatchAppInstalled', + null, + const Duration(seconds: 2), + ); + _watchAppInstalledCache = result == true; + return _watchAppInstalledCache; + } + + static Future clearICloudToken({bool notifyWatch = false}) async { + if (!Platform.isIOS) return; + await _invokeMethodWithTimeout( + 'clearICloudToken', + null, + const Duration(seconds: 5), + ); + if (notifyWatch) { + await notifyWatchForceLogout(); + } + } + + static Future notifyWatchForceLogout() async { + if (!Platform.isIOS) return; + final watchInstalled = await isWatchAppInstalled(forceRefresh: true); + if (!watchInstalled) return; + await _invokeMethodWithTimeout( + 'sendLogoutToWatch', + null, + const Duration(seconds: 5), + ); + } + + static Future runFreshInstallCleanupIfNeeded({ + required Isar isar, + }) async { + if (!Platform.isIOS) return false; + + final prefs = await SharedPreferences.getInstance(); + final cleanupHandled = prefs.getBool(_iosFreshInstallHandledKey) ?? false; + if (cleanupHandled) { + return false; + } + + debugPrint( + '[WatchSync] Fresh iOS install detected, clearing iCloud and local auth state'); + await clearICloudToken(notifyWatch: true); + + await isar.writeTxn(() async { + await isar.tokenModels.clear(); + }); + + if (initDone) { + initData.tokens = []; + } + KretaClient.clearReauthFlag(); + + await prefs.setBool(_iosFreshInstallHandledKey, true); + return true; + } + static Future _handleMethodCall(MethodCall call) async { switch (call.method) { case 'getTokenForWatch': @@ -229,6 +317,8 @@ class WatchSyncHelper { return _getLanguageForWatch(); case 'watchAppInstalled': debugPrint('[WatchSync] Watch app installed detected'); + _watchAppInstalledCache = true; + _lastWatchInstallCheckAt = DateTime.now(); return null; case 'onTokenFromWatch': debugPrint('[WatchSync] Token received from Watch'); @@ -301,6 +391,12 @@ class WatchSyncHelper { return {'error': 'token_incomplete'}; } + if (!_isAccessTokenUsable(token.expiryDate, skew: const Duration())) { + debugPrint( + '[WatchSync] Active iPhone token is expired, not sending to Watch'); + return {'error': 'needsReauth'}; + } + if (KretaClient.needsReauth) { debugPrint('[WatchSync] iPhone needs reauth'); return {'error': 'needsReauth'}; @@ -361,6 +457,11 @@ class WatchSyncHelper { ); final watchExpiryDate = DateTime.fromMillisecondsSinceEpoch(watchExpiry); + if (!_isAccessTokenUsable(watchExpiryDate, skew: const Duration())) { + debugPrint( + '[WatchSync] Rejecting expired token from Watch, expiry: $watchExpiryDate'); + return {'success': false, 'error': 'expired_token'}; + } final watchTokenVersion = _resolveIncomingTokenVersion(tokenData); final watchUpdatedAtMs = _asInt(tokenData['updatedAtMs']); final watchIdToken = tokenData['idToken'] as String?; @@ -431,6 +532,11 @@ class WatchSyncHelper { return; } + if (!_isAccessTokenUsable(token.expiryDate, skew: const Duration())) { + debugPrint('[WatchSync] Token expired, not sending to Watch'); + return; + } + final tokenData = _buildTokenSyncPayload(token, includeSentAt: true); await _invokeMethodWithTimeout('sendTokenToWatch', tokenData); @@ -466,6 +572,7 @@ class WatchSyncHelper { Isar? isar, List? tokens, KretaClient? client, + bool allowExpiredAccessToken = false, }) async { if (!Platform.isIOS) return false; @@ -515,6 +622,13 @@ class WatchSyncHelper { final iCloudExpiryDate = DateTime.fromMillisecondsSinceEpoch(iCloudExpiry); + final iCloudAccessExpired = + !_isAccessTokenUsable(iCloudExpiryDate, skew: const Duration()); + if (iCloudAccessExpired && !allowExpiredAccessToken) { + debugPrint( + '[WatchSync] iCloud token access is expired (expiry: $iCloudExpiryDate), skipping direct apply'); + return false; + } final iCloudTokenVersion = _resolveIncomingTokenVersion(tokenData); final iCloudUpdatedAtMs = _asInt(tokenData['updatedAtMs']); final iCloudIdToken = tokenData['idToken'] as String?; @@ -569,8 +683,10 @@ class WatchSyncHelper { effectiveClient.model = newToken; } - if (expectedStudentIdNorm == null || - newToken.studentIdNorm == expectedStudentIdNorm) { + final shouldClearReauth = !iCloudAccessExpired && + (expectedStudentIdNorm == null || + newToken.studentIdNorm == expectedStudentIdNorm); + if (shouldClearReauth) { KretaClient.clearReauthFlag(); } @@ -599,6 +715,13 @@ class WatchSyncHelper { return; } + final watchInstalled = await isWatchAppInstalled(); + if (!watchInstalled) { + debugPrint( + '[WatchSync] Skipping iCloud token save because no paired Watch app is installed'); + return; + } + final tokenData = _buildTokenSyncPayload(token); await _invokeMethodWithTimeout( @@ -690,6 +813,20 @@ class WatchSyncHelper { } final watchExpiryDate = DateTime.fromMillisecondsSinceEpoch(watchExpiry); + if (!_isAccessTokenUsable(watchExpiryDate, skew: const Duration())) { + debugPrint( + '[WatchSync] Watch provided expired token, ignoring and keeping iPhone token'); + if (currentToken != null && + currentToken.accessToken != null && + currentToken.refreshToken != null && + currentToken.expiryDate != null && + _isAccessTokenUsable(currentToken.expiryDate, + skew: const Duration()) && + !KretaClient.needsReauth) { + await _sendTokenToWatchInternal(currentToken); + } + return; + } final watchTokenVersion = _resolveIncomingTokenVersion(tokenData); final watchUpdatedAtMs = _asInt(tokenData['updatedAtMs']); final watchIdToken = tokenData['idToken'] as String?; diff --git a/firka/lib/main.dart b/firka/lib/main.dart index d780fbab..47d52a46 100644 --- a/firka/lib/main.dart +++ b/firka/lib/main.dart @@ -205,14 +205,22 @@ Future _initData(AppInitialization init) async { resetOldTimeTableCache(init.isar); resetOldHomeworkCache(init.isar); + var didRunFreshInstallCleanup = false; if (Platform.isIOS) { try { - await WatchSyncHelper.checkAndRecoverFromiCloud( - isar: init.isar, - tokens: init.tokens, - ); + didRunFreshInstallCleanup = + await WatchSyncHelper.runFreshInstallCleanupIfNeeded(isar: init.isar); + if (didRunFreshInstallCleanup) { + logger.info( + '[Init] Fresh-install cleanup completed; skipping startup iCloud recovery on this launch'); + } else { + await WatchSyncHelper.checkAndRecoverFromiCloud( + isar: init.isar, + tokens: init.tokens, + ); + } } catch (e) { - logger.warning('[Init] iCloud recovery check failed: $e'); + logger.warning('[Init] iCloud bootstrap/recovery failed: $e'); } } diff --git a/firka/lib/ui/phone/screens/home/home_screen.dart b/firka/lib/ui/phone/screens/home/home_screen.dart index 28929d3f..027b081d 100644 --- a/firka/lib/ui/phone/screens/home/home_screen.dart +++ b/firka/lib/ui/phone/screens/home/home_screen.dart @@ -488,7 +488,7 @@ class _HomeScreenState extends FirkaState { if (Platform.isIOS && widget.data.settings.group("settings").boolean("beta_warning")) { - Future.delayed(Duration(seconds: 5), () async { + Future.delayed(Duration(seconds: 3), () async { await LiveActivityService.showConsentScreenIfNeeded(); }); } diff --git a/firka/lib/ui/phone/screens/settings/settings_screen.dart b/firka/lib/ui/phone/screens/settings/settings_screen.dart index fec21956..5cc7ae3d 100644 --- a/firka/lib/ui/phone/screens/settings/settings_screen.dart +++ b/firka/lib/ui/phone/screens/settings/settings_screen.dart @@ -23,8 +23,10 @@ import 'package:url_launcher/url_launcher_string.dart'; import '../../../../helpers/db/widget.dart'; import '../../../../helpers/firka_bundle.dart'; import '../../../../helpers/firka_state.dart'; +import '../../../../helpers/api/client/kreta_client.dart'; import '../../../../helpers/settings.dart'; import '../../../../helpers/live_activity_service.dart'; +import '../../../../helpers/watch_sync_helper.dart'; import '../../widgets/login_webview.dart'; class SettingsScreen extends StatefulWidget { @@ -149,12 +151,14 @@ class _SettingsScreenState extends FirkaState { widgets.add(GestureDetector( onTap: () { - if (item.redirectTo != null && item.redirectTo == "discord"){ - launchUrlString("https://discord.com/invite/firka-1111649116020285532"); - return; - } else if (item.redirectTo != null && item.redirectTo == "privacy"){ + if (item.redirectTo != null && item.redirectTo == "discord") { + launchUrlString( + "https://discord.com/invite/firka-1111649116020285532"); + return; + } else if (item.redirectTo != null && + item.redirectTo == "privacy") { launchUrlString("https://firka.app/privacy"); - return; + return; } else { Navigator.push( context, @@ -164,10 +168,18 @@ class _SettingsScreenState extends FirkaState { child: SettingsScreen(widget.data, item.children)))); } }, - child: item.redirectTo != null - ? FirkaCard(left: cardWidgets, right: [RotationTransition(turns: AlwaysStoppedAnimation(-45/360), child: FirkaIconWidget(FirkaIconType.majesticons, Majesticon.arrowRightSolid, size: 24, color: appStyle.colors.textSecondary))],) - : FirkaCard(left: cardWidgets), - + child: item.redirectTo != null + ? FirkaCard( + left: cardWidgets, + right: [ + RotationTransition( + turns: AlwaysStoppedAnimation(-45 / 360), + child: FirkaIconWidget(FirkaIconType.majesticons, + Majesticon.arrowRightSolid, + size: 24, color: appStyle.colors.textSecondary)) + ], + ) + : FirkaCard(left: cardWidgets), )); continue; @@ -177,28 +189,24 @@ class _SettingsScreenState extends FirkaState { var v = item.toRoundedString(); widgets.add(GestureDetector( - child: FirkaCard( - height: 52 + 12, - left: [ - item.iconType != null - ? Row( - children: [ - FirkaIconWidget(item.iconType!, item.iconData!, - color: appStyle.colors.accent), - SizedBox(width: 4), - ], - ) - : SizedBox(), - Text(item.title, - style: appStyle.fonts.B_16SB - .apply(color: appStyle.colors.textPrimary)) - ], - right: [ - Text(v == "0.0" ? "0" : v, - style: appStyle.fonts.B_16R - .apply(color: appStyle.colors.textPrimary)) - ] - ), + child: FirkaCard(height: 52 + 12, left: [ + item.iconType != null + ? Row( + children: [ + FirkaIconWidget(item.iconType!, item.iconData!, + color: appStyle.colors.accent), + SizedBox(width: 4), + ], + ) + : SizedBox(), + Text(item.title, + style: appStyle.fonts.B_16SB + .apply(color: appStyle.colors.textPrimary)) + ], right: [ + Text(v == "0.0" ? "0" : v, + style: appStyle.fonts.B_16R + .apply(color: appStyle.colors.textPrimary)) + ]), onTap: () async { showSetDoubleSheet(context, item, widget.data, setState); }, @@ -212,12 +220,12 @@ class _SettingsScreenState extends FirkaState { left: [ item.iconType != null ? Row( - children: [ - FirkaIconWidget(item.iconType!, item.iconData!, - color: appStyle.colors.accent), - SizedBox(width: 4), - ], - ) + children: [ + FirkaIconWidget(item.iconType!, item.iconData!, + color: appStyle.colors.accent), + SizedBox(width: 4), + ], + ) : SizedBox(), Text(item.title, style: appStyle.fonts.B_16SB @@ -316,67 +324,74 @@ class _SettingsScreenState extends FirkaState { continue; } if (item is ShowLicensePage) { - widgets.add( - FutureBuilder>( - future: LicenseRegistry.licenses.toList(), - builder: (BuildContext context, AsyncSnapshot> snapshot) { - if (!snapshot.hasData) { - return Center(child: CircularProgressIndicator(color: appStyle.colors.accent)); - } - final licenses = snapshot.data!; - final shownPackages = {}; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: licenses.where((license) { - return license.packages.any((pkg) => !shownPackages.contains(pkg)); - }).map((license) { - final packageName = license.packages.firstWhere( - (pkg) => !shownPackages.contains(pkg), - orElse: () => license.packages.first, - ); - shownPackages.add(packageName); - final paragraphs = license.paragraphs.map((p) => p.text).join('\n\n'); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - onTap: () { - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: Text(packageName, style: TextStyle(fontWeight: FontWeight.bold)), - content: SingleChildScrollView( - child: Text(paragraphs), - ), - actions: [ - TextButton( - child: Text(widget.data.l10n.close), - onPressed: () { - Navigator.of(context).pop(); - }, + widgets.add(FutureBuilder>( + future: LicenseRegistry.licenses.toList(), + builder: (BuildContext context, + AsyncSnapshot> snapshot) { + if (!snapshot.hasData) { + return Center( + child: + CircularProgressIndicator(color: appStyle.colors.accent)); + } + final licenses = snapshot.data!; + final shownPackages = {}; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: licenses.where((license) { + return license.packages + .any((pkg) => !shownPackages.contains(pkg)); + }).map((license) { + final packageName = license.packages.firstWhere( + (pkg) => !shownPackages.contains(pkg), + orElse: () => license.packages.first, + ); + shownPackages.add(packageName); + final paragraphs = + license.paragraphs.map((p) => p.text).join('\n\n'); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(packageName, + style: + TextStyle(fontWeight: FontWeight.bold)), + content: SingleChildScrollView( + child: Text(paragraphs), ), - ], - ); - }, - ); - }, - child: FirkaCard(left: [ - Text( - packageName, - style: appStyle.fonts.B_14R.apply(color: appStyle.colors.textPrimary), - ), - ]), - ) - ], - ), - );}).toList(), - ); - }, - ) - ); + actions: [ + TextButton( + child: Text(widget.data.l10n.close), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + }, + child: FirkaCard(left: [ + Text( + packageName, + style: appStyle.fonts.B_14R + .apply(color: appStyle.colors.textPrimary), + ), + ]), + ) + ], + ), + ); + }).toList(), + ); + }, + )); continue; } @@ -718,11 +733,19 @@ class _SettingsScreenState extends FirkaState { await item.save(widget.data.isar.appSettingsModels); }); - final accounts = await widget.data.isar.tokenModels.where().findAll(); if (accounts.isEmpty) { + if (Platform.isIOS) { + try { + await WatchSyncHelper.clearICloudToken(notifyWatch: true); + } catch (e) { + logger.warning('[Settings] Failed to clear iCloud token: $e'); + } + KretaClient.clearReauthFlag(); + } + if (!mounted) return; Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( builder: (context) => LoginScreen(widget.data)), @@ -919,7 +942,10 @@ void showSetDoubleSheet(BuildContext context, SettingsDouble setting, value: setting.value, max: setting.maxValue, divisions: setting.step != null - ? ((setting.maxValue - setting.minValue) / setting.step!).round() + ? ((setting.maxValue - + setting.minValue) / + setting.step!) + .round() : null, thumbColor: appStyle.colors.accent, activeColor: appStyle.colors.secondary, @@ -927,7 +953,9 @@ void showSetDoubleSheet(BuildContext context, SettingsDouble setting, onChanged: (v) async { setState(() { if (setting.step != null) { - setting.value = (v / setting.step!).round() * setting.step!; + setting.value = + (v / setting.step!).round() * + setting.step!; } else { setting.value = v; } diff --git a/firka/lib/ui/phone/widgets/login_webview.dart b/firka/lib/ui/phone/widgets/login_webview.dart index 1f4d6680..59dcedb4 100644 --- a/firka/lib/ui/phone/widgets/login_webview.dart +++ b/firka/lib/ui/phone/widgets/login_webview.dart @@ -90,14 +90,18 @@ class _LoginWebviewWidgetState extends FirkaState { await accountPicker.postUpdate(); if (Platform.isIOS) { - try { - await WatchSyncHelper.saveTokenToiCloud(tokenModel); - } catch (_) {} + final watchInstalled = + await WatchSyncHelper.isWatchAppInstalled(); + if (watchInstalled) { + try { + await WatchSyncHelper.saveTokenToiCloud(tokenModel); + } catch (_) {} - try { - await WatchSyncHelper.sendTokenToWatch(); - } catch (e) { - // Watch may not be available, ignore + try { + await WatchSyncHelper.sendTokenToWatch(); + } catch (_) { + // Watch may be unavailable, ignore + } } }