1
0
forked from firka/firka

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.
This commit is contained in:
Horváth Gergely
2026-02-09 15:34:14 +01:00
committed by 4831c0
parent 12e3fa5bff
commit dbf0d18e5c
7 changed files with 157 additions and 25 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ?? ""
}
}

View File

@@ -77,13 +77,24 @@ class KretaClient {
static Future<void> _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;

View File

@@ -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<TokenGrantResponse> getAccessToken(String code) async {
@@ -76,11 +80,30 @@ Future<TokenGrantResponse> 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}");