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.
This commit is contained in:
Horváth Gergely
2026-02-11 20:42:15 +01:00
committed by 4831c0
parent e620f3e564
commit 7c344be550
8 changed files with 397 additions and 116 deletions

View File

@@ -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")

View File

@@ -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])
}

View File

@@ -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) {

View File

@@ -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<T?> _invokeMethodWithTimeout<T>(
@@ -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<String, dynamic> _buildTokenSyncPayload(
TokenModel token, {
bool includeSentAt = false,
@@ -221,6 +236,79 @@ class WatchSyncHelper {
));
}
static Future<bool> 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<bool>(
'isWatchAppInstalled',
null,
const Duration(seconds: 2),
);
_watchAppInstalledCache = result == true;
return _watchAppInstalledCache;
}
static Future<void> clearICloudToken({bool notifyWatch = false}) async {
if (!Platform.isIOS) return;
await _invokeMethodWithTimeout(
'clearICloudToken',
null,
const Duration(seconds: 5),
);
if (notifyWatch) {
await notifyWatchForceLogout();
}
}
static Future<void> 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<bool> 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<dynamic> _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<TokenModel>? 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?;

View File

@@ -205,14 +205,22 @@ Future<void> _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');
}
}

View File

@@ -488,7 +488,7 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
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();
});
}

View File

@@ -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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
continue;
}
if (item is ShowLicensePage) {
widgets.add(
FutureBuilder<List<LicenseEntry>>(
future: LicenseRegistry.licenses.toList(),
builder: (BuildContext context, AsyncSnapshot<List<LicenseEntry>> snapshot) {
if (!snapshot.hasData) {
return Center(child: CircularProgressIndicator(color: appStyle.colors.accent));
}
final licenses = snapshot.data!;
final shownPackages = <String>{};
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<List<LicenseEntry>>(
future: LicenseRegistry.licenses.toList(),
builder: (BuildContext context,
AsyncSnapshot<List<LicenseEntry>> snapshot) {
if (!snapshot.hasData) {
return Center(
child:
CircularProgressIndicator(color: appStyle.colors.accent));
}
final licenses = snapshot.data!;
final shownPackages = <String>{};
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<SettingsScreen> {
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;
}

View File

@@ -90,14 +90,18 @@ class _LoginWebviewWidgetState extends FirkaState<LoginWebviewWidget> {
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
}
}
}