From dbf0d18e5cd9989c0bac742ba3f01d8f4b96771f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20Gergely?= Date: Mon, 9 Feb 2026 15:34:14 +0100 Subject: [PATCH] Handle percentage grades and token recovery Add support for percentage-style grades and normalize them to standard grade scale across the watch app and shared models; show raw percentage in grade badge UI while keeping normalized coloring. Update WidgetGrade/KretaGrade to include valueType, add isPercentageGrade, percentageToGrade, normalizedNumericValue, displayTypeWithWeight and displayGradeValue helpers, and use normalized values in DataStore and views. Improve HomeView refresh button to reflect background loading, disable during datastore loading, and auto-transition success/failure states when background sync finishes. Enhance token recovery logic in Dart: KretaClient attempts watch/iCloud recovery twice (with a short delay) before forcing reauth, and token_grant tries to recover a fresher token from iCloud via WatchSyncHelper on 401 responses before throwing token errors. --- .../Services/DataStore.swift | 2 +- .../Views/GradeSubjectView.swift | 27 ++++++++-- .../FirkaWatch Watch App/Views/HomeView.swift | 53 +++++++++++++++---- firka/ios/Shared/API/KretaAPIModels.swift | 7 +++ firka/ios/Shared/Models/Grade.swift | 43 ++++++++++++++- .../lib/helpers/api/client/kreta_client.dart | 19 +++++-- firka/lib/helpers/api/token_grant.dart | 31 +++++++++-- 7 files changed, 157 insertions(+), 25 deletions(-) diff --git a/firka/ios/FirkaWatch Watch App/Services/DataStore.swift b/firka/ios/FirkaWatch Watch App/Services/DataStore.swift index 6508221..56819ea 100644 --- a/firka/ios/FirkaWatch Watch App/Services/DataStore.swift +++ b/firka/ios/FirkaWatch Watch App/Services/DataStore.swift @@ -425,7 +425,7 @@ class DataStore { var subjectGradesMap: [String: [(value: Int, weight: Double)]] = [:] for grade in grades { - if let numeric = grade.numericValue { + if let numeric = grade.normalizedNumericValue { let key = grade.subject.uid let weight = Double(grade.weightPercentage ?? 100) / 100.0 subjectGradesMap[key, default: []].append((value: numeric, weight: weight)) diff --git a/firka/ios/FirkaWatch Watch App/Views/GradeSubjectView.swift b/firka/ios/FirkaWatch Watch App/Views/GradeSubjectView.swift index 5b5332c..daae6a5 100644 --- a/firka/ios/FirkaWatch Watch App/Views/GradeSubjectView.swift +++ b/firka/ios/FirkaWatch Watch App/Views/GradeSubjectView.swift @@ -51,8 +51,19 @@ struct GradeSubjectView: View { private func gradeRow(_ grade: WidgetGrade) -> some View { FirkaCard { HStack(alignment: .top, spacing: 10) { - if let numeric = grade.numericValue { - GradeBadge(grade: numeric) + if let normalizedValue = grade.normalizedNumericValue { + if grade.isPercentageGrade, let rawValue = grade.numericValue { + ZStack { + Circle() + .fill(gradeColor(normalizedValue)) + .frame(width: 32, height: 32) + Text("\(rawValue)%") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.white) + } + } else { + GradeBadge(grade: normalizedValue) + } } else { Text(grade.displayValue) .font(.caption) @@ -63,7 +74,7 @@ struct GradeSubjectView: View { } VStack(alignment: .leading, spacing: 2) { - Text(grade.displayType) + Text(grade.displayTypeWithWeight) .font(.subheadline) .fontWeight(.medium) @@ -80,6 +91,16 @@ struct GradeSubjectView: View { } } + private func gradeColor(_ value: Int) -> Color { + switch value { + case 5: return .green + case 4: return .blue + case 3: return .yellow + case 2: return .orange + default: return .red + } + } + private func formatDate(_ date: Date) -> String { let formatter = DateFormatter() formatter.dateFormat = "yyyy. MM. dd." diff --git a/firka/ios/FirkaWatch Watch App/Views/HomeView.swift b/firka/ios/FirkaWatch Watch App/Views/HomeView.swift index 0befcb3..c0241fc 100644 --- a/firka/ios/FirkaWatch Watch App/Views/HomeView.swift +++ b/firka/ios/FirkaWatch Watch App/Views/HomeView.swift @@ -42,6 +42,8 @@ struct HomeView: View { // MARK: - Refresh Button @State private var refreshStatus: RefreshStatus = .idle + @State private var wasLoadingFromBackground: Bool = false + @State private var lastUpdateTime: Date? = nil enum RefreshStatus { case idle, loading, success, failure @@ -49,6 +51,7 @@ struct HomeView: View { private var refreshButton: some View { Button(action: { + guard !dataStore.isLoading else { return } Task { refreshStatus = .loading await dataStore.refreshAll() @@ -62,18 +65,23 @@ struct HomeView: View { } }) { HStack(spacing: 6) { - switch refreshStatus { - case .idle: - Image(systemName: "arrow.clockwise") - case .loading: + if dataStore.isLoading && refreshStatus != .loading { ProgressView() .scaleEffect(0.8) - case .success: - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - case .failure: - Image(systemName: "xmark.circle.fill") - .foregroundColor(.red) + } else { + switch refreshStatus { + case .idle: + Image(systemName: "arrow.clockwise") + case .loading: + ProgressView() + .scaleEffect(0.8) + case .success: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + case .failure: + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + } } Text(refreshStatusText) } @@ -81,11 +89,34 @@ struct HomeView: View { .foregroundColor(.blue) } .buttonStyle(.plain) - .disabled(refreshStatus == .loading) + .disabled(dataStore.isLoading || refreshStatus == .loading) .padding(.top, 8) + .onChange(of: dataStore.isLoading) { oldValue, newValue in + if newValue && refreshStatus == .idle { + wasLoadingFromBackground = true + } + if !newValue && wasLoadingFromBackground && refreshStatus == .idle { + wasLoadingFromBackground = false + if dataStore.error == nil && dataStore.data != nil { + refreshStatus = .success + } else if dataStore.error != nil { + refreshStatus = .failure + } + Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + if refreshStatus == .success || refreshStatus == .failure { + refreshStatus = .idle + } + } + } + } } private var refreshStatusText: String { + if dataStore.isLoading && refreshStatus != .loading { + return "refreshing".localized + } + switch refreshStatus { case .idle: return "refresh".localized case .loading: return "refreshing".localized diff --git a/firka/ios/Shared/API/KretaAPIModels.swift b/firka/ios/Shared/API/KretaAPIModels.swift index 7046ff6..2d73dc9 100644 --- a/firka/ios/Shared/API/KretaAPIModels.swift +++ b/firka/ios/Shared/API/KretaAPIModels.swift @@ -112,6 +112,7 @@ struct KretaGrade: Decodable { let subject: KretaSubject let topic: String? let type: KretaNameUidDesc + let valueType: KretaNameUidDesc? let numericValue: Int? let strValue: String? let weightPercentage: Int? @@ -122,6 +123,7 @@ struct KretaGrade: Decodable { case subject = "Tantargy" case topic = "Tema" case type = "Tipus" + case valueType = "ErtekFajta" case numericValue = "SzamErtek" case strValue = "SzovegesErtek" case weightPercentage = "SulySzazalekErteke" @@ -144,12 +146,17 @@ struct KretaGrade: Decodable { description: type.description ) + let widgetValueType = valueType.map { vt in + NameUidDesc(uid: vt.uid, name: vt.name, description: vt.description) + } + return WidgetGrade( uid: uid, recordDate: recordDate, subject: widgetSubject, topic: topic, type: widgetType, + valueType: widgetValueType, numericValue: numericValue, strValue: strValue, weightPercentage: weightPercentage diff --git a/firka/ios/Shared/Models/Grade.swift b/firka/ios/Shared/Models/Grade.swift index f225fc6..3ee5efe 100644 --- a/firka/ios/Shared/Models/Grade.swift +++ b/firka/ios/Shared/Models/Grade.swift @@ -7,6 +7,7 @@ struct WidgetGrade: Codable, Identifiable { let subject: WidgetSubject let topic: String? let type: NameUidDesc + let valueType: NameUidDesc? let numericValue: Int? let strValue: String? let weightPercentage: Int? @@ -14,17 +15,38 @@ struct WidgetGrade: Codable, Identifiable { var id: String { uid } init(uid: String, recordDate: Date, subject: WidgetSubject, topic: String?, - type: NameUidDesc, numericValue: Int?, strValue: String?, weightPercentage: Int?) { + type: NameUidDesc, valueType: NameUidDesc?, numericValue: Int?, strValue: String?, weightPercentage: Int?) { self.uid = uid self.recordDate = recordDate self.subject = subject self.topic = topic self.type = type + self.valueType = valueType self.numericValue = numericValue self.strValue = strValue self.weightPercentage = weightPercentage } + var isPercentageGrade: Bool { + valueType?.name.lowercased().contains("szazalekos") ?? false + } + + static func percentageToGrade(_ percentage: Int) -> Int { + if percentage < 50 { return 1 } + if percentage < 60 { return 2 } + if percentage < 70 { return 3 } + if percentage < 80 { return 4 } + return 5 + } + + var normalizedNumericValue: Int? { + guard let numeric = numericValue else { return nil } + if isPercentageGrade { + return WidgetGrade.percentageToGrade(numeric) + } + return numeric + } + var displayValue: String { if let numeric = numericValue { return "\(numeric)" @@ -39,7 +61,7 @@ struct WidgetGrade: Codable, Identifiable { } var gradeColor: Color { - guard let value = numericValue else { return .gray } + guard let value = normalizedNumericValue else { return .gray } switch value { case 5: return .green case 4: return .blue @@ -77,4 +99,21 @@ extension WidgetGrade { ] return typeMap[type.name.lowercased()] ?? type.name.replacingOccurrences(of: "_", with: " ").capitalized } + + var displayTypeWithWeight: String { + if let weight = weightPercentage, weight != 100 { + return "\(displayType) (\(weight)%)" + } + return displayType + } + + var displayGradeValue: String { + if isPercentageGrade, let numeric = numericValue { + return "\(numeric)%" + } + if let numeric = numericValue { + return "\(numeric)" + } + return strValue ?? "" + } } diff --git a/firka/lib/helpers/api/client/kreta_client.dart b/firka/lib/helpers/api/client/kreta_client.dart index 8d98e10..bb4f308 100644 --- a/firka/lib/helpers/api/client/kreta_client.dart +++ b/firka/lib/helpers/api/client/kreta_client.dart @@ -77,13 +77,24 @@ class KretaClient { static Future _setReauthFlag() async { if (Platform.isIOS && !needsReauth) { - debugPrint('[KretaClient] Token expired, trying to recover from Watch first...'); - final recovered = await _tryRecoverFromWatch(); + debugPrint('[KretaClient] Token expired, trying to recover from Watch/iCloud first...'); + + var recovered = await _tryRecoverFromWatch(); if (recovered) { - debugPrint('[KretaClient] Successfully recovered token from Watch, reauth not needed'); + debugPrint('[KretaClient] Successfully recovered token (attempt 1), reauth not needed'); return; } - debugPrint('[KretaClient] Could not recover from Watch, setting reauth flag'); + + debugPrint('[KretaClient] First recovery failed, waiting for iCloud sync...'); + await Future.delayed(const Duration(milliseconds: 1500)); + + recovered = await _tryRecoverFromWatch(); + if (recovered) { + debugPrint('[KretaClient] Successfully recovered token (attempt 2), reauth not needed'); + return; + } + + debugPrint('[KretaClient] Could not recover from Watch/iCloud, setting reauth flag'); } needsReauth = true; diff --git a/firka/lib/helpers/api/token_grant.dart b/firka/lib/helpers/api/token_grant.dart index a1456a8..498fb12 100644 --- a/firka/lib/helpers/api/token_grant.dart +++ b/firka/lib/helpers/api/token_grant.dart @@ -1,9 +1,13 @@ +import 'dart:io'; + import 'package:dio/dio.dart'; import 'package:firka/helpers/api/exceptions/token.dart'; import 'package:firka/helpers/api/resp/token_grant.dart'; import 'package:firka/helpers/db/models/token_model.dart'; +import 'package:flutter/foundation.dart'; import '../../main.dart'; +import '../watch_sync_helper.dart'; import 'consts.dart'; Future getAccessToken(String code) async { @@ -76,11 +80,30 @@ Future extendToken(TokenModel model) async { logger.info("Token extended successfully for user: ${model.studentId}"); return TokenGrantResponse.fromJson(response.data); case 400: - logger.warning("Token refresh failed (400) - refresh token expired for user: ${model.studentId}"); - throw TokenExpiredException(); case 401: - logger.warning("Token refresh failed (401) - invalid grant for user: ${model.studentId}"); - throw InvalidGrantException(); + if (Platform.isIOS && initDone) { + debugPrint('[TokenGrant] Got ${response.statusCode}, checking iCloud for fresher token...'); + final recovered = await WatchSyncHelper.checkAndRecoverFromiCloud( + isar: initData.isar, + tokens: initData.tokens, + client: initData.client, + ); + if (recovered) { + debugPrint('[TokenGrant] Found fresher token in iCloud! Using it instead of failing.'); + final freshToken = initData.tokens.first; + return TokenGrantResponse( + accessToken: freshToken.accessToken!, + refreshToken: freshToken.refreshToken!, + idToken: freshToken.idToken ?? '', + expiresIn: freshToken.expiryDate!.difference(DateTime.now()).inSeconds, + tokenType: 'Bearer', + scope: 'openid', + ); + } + debugPrint('[TokenGrant] No fresher token in iCloud, token truly expired'); + } + logger.warning("Token refresh failed (${response.statusCode}) - refresh token invalid for user: ${model.studentId}"); + throw response.statusCode == 400 ? TokenExpiredException() : InvalidGrantException(); default: logger.warning("Token refresh failed (${response.statusCode}) for user: ${model.studentId}, attempt ${attempt + 1}"); lastError = Exception("Failed to get access token, response code: ${response.statusCode}");