diff --git a/firka/ios/FirkaWatch Watch App/Services/DataStore.swift b/firka/ios/FirkaWatch Watch App/Services/DataStore.swift index 65082211..56819ea0 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 5b5332c2..daae6a5c 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 0befcb32..c0241fce 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 7046ff61..2d73dc90 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 f225fc64..3ee5efe2 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 8d98e10b..bb4f308b 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 a1456a8d..498fb120 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}");