forked from firka/firka
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:
@@ -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")
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user