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:
@@ -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))
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
|
||||
Reference in New Issue
Block a user