firka_wear: local sync store, 1h rule, remove KretaClient; data from phone only

This commit is contained in:
2026-03-01 12:53:48 +01:00
parent 4811519ced
commit 59b470a64c
8 changed files with 356 additions and 875 deletions

View File

@@ -1,519 +0,0 @@
import 'dart:convert';
import 'dart:math';
import 'package:dio/dio.dart';
import 'package:firka_wear/helpers/api/model/homework.dart';
import 'package:firka_wear/helpers/api/model/timetable.dart';
import 'package:firka_wear/helpers/db/models/generic_cache_model.dart';
import 'package:firka_wear/helpers/db/models/homework_cache_model.dart';
import 'package:firka_wear/helpers/db/models/timetable_cache_model.dart';
import 'package:intl/intl.dart';
import 'package:isar_community/isar.dart';
import '../../../main.dart';
import '../../db/models/token_model.dart';
import '../../db/util.dart';
import '../../debug_helper.dart';
import '../consts.dart';
import '../model/grade.dart';
import '../model/notice_board.dart';
import '../model/omission.dart';
import '../model/student.dart';
import '../model/test.dart';
import '../token_grant.dart';
class ApiResponse<T> {
T? response;
int statusCode;
String? err;
bool cached;
ApiResponse(
this.response,
this.statusCode,
this.err,
this.cached,
);
@override
String toString() {
return "ApiResponse("
"response: $response, "
"statusCode: $statusCode, "
"err: \"$err\", "
"cached: $cached"
")";
}
}
class KretaClient {
bool _tokenMutex = false;
TokenModel model;
Isar isar;
KretaClient(this.model, this.isar);
Future<T> _mutexCallback<T>(Future<T> Function() callback) async {
while (_tokenMutex) {
await Future.delayed(const Duration(milliseconds: 50));
}
_tokenMutex = true;
try {
return callback();
} finally {
_tokenMutex = false;
}
}
Future<Response> _authReq(String method, String url, [Object? data]) async {
var localToken = await _mutexCallback<String>(() async {
var now = timeNow();
if (now.millisecondsSinceEpoch >=
model.expiryDate!.millisecondsSinceEpoch) {
var extended = await extendToken(model);
var tokenModel = TokenModel.fromResp(extended);
await isar.writeTxn(() async {
await isar.tokenModels.put(tokenModel);
});
model = tokenModel;
}
return model.accessToken!;
});
final headers = <String, String>{
// "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"accept": "*/*",
"user-agent": "eKretaStudent/264745 CFNetwork/1494.0.7 Darwin/23.4.0",
"authorization": "Bearer $localToken",
"apiKey": "21ff6c25-d1da-4a68-a811-c881a6057463"
};
return await dio.get(url,
options: Options(method: method, headers: headers), data: data);
}
Future<(dynamic, int)> _authJson(String method, String url,
[Object? data]) async {
var resp = await _authReq(method, url, data);
return (resp.data, resp.statusCode!);
}
Future<(dynamic, int, Object?, bool)> _cachingGet(
CacheId id, String url, bool forceCache) async {
// it would be *ideal* to use xor and left shift here, however
// binary operations seem to round the number down to
// 32 bits for some reason???
var cacheKey = model.studentIdNorm! + ((id.index + 1) * pow(10, 11));
var cache = await isar.genericCacheModels.get(cacheKey as int);
dynamic resp;
int statusCode;
try {
if (forceCache && cache != null) {
return (jsonDecode(cache.cacheData!), 200, null, true);
}
(resp, statusCode) = await _authJson("GET", url);
if (statusCode >= 400) {
if (cache != null) {
return (jsonDecode(cache.cacheData!), statusCode, null, true);
}
}
} catch (ex) {
if (cache != null) {
return (jsonDecode(cache.cacheData!), 0, ex, true);
} else {
return (null, 0, ex, false);
}
}
await isar.writeTxn(() async {
var cache = GenericCacheModel();
cache.cacheKey = cacheKey;
cache.cacheData = jsonEncode(resp);
isar.genericCacheModels.put(cache);
});
return (resp, statusCode, null, false);
}
ApiResponse<Student>? studentCache;
Future<ApiResponse<Student>> getStudent({bool forceCache = true}) async {
if (forceCache && studentCache != null) return studentCache!;
var (resp, status, ex, cached) = await _cachingGet(CacheId.getStudent,
KretaEndpoints.getStudentUrl(model.iss!), forceCache);
Student? student;
String? err;
try {
student = Student.fromJson(resp);
} catch (ex) {
err = ex.toString();
}
if (ex != null) {
err = ex.toString();
}
if (ex == null) studentCache = ApiResponse(student, 200, null, true);
return ApiResponse(student, status, err, cached);
}
ApiResponse<List<NoticeBoardItem>>? noticeBoardCache;
Future<ApiResponse<List<NoticeBoardItem>>> getNoticeBoard(
{bool forceCache = true}) async {
if (forceCache && noticeBoardCache != null) return noticeBoardCache!;
var (resp, status, ex, cached) = await _cachingGet(CacheId.getNoticeBoard,
KretaEndpoints.getNoticeBoard(model.iss!), forceCache);
var items = List<NoticeBoardItem>.empty(growable: true);
String? err;
try {
List<dynamic> rawItems = resp;
for (var item in rawItems) {
items.add(NoticeBoardItem.fromJson(item));
}
} catch (ex) {
err = ex.toString();
}
if (ex != null) {
err = ex.toString();
}
if (err == null) noticeBoardCache = ApiResponse(items, 200, null, true);
return ApiResponse(items, status, err, cached);
}
ApiResponse<List<Grade>>? gradeCache;
Future<ApiResponse<List<Grade>>> getGrades({bool forceCache = true}) async {
if (forceCache && gradeCache != null) {
return gradeCache!;
}
var (resp, status, ex, cached) = await _cachingGet(
CacheId.getGrades, KretaEndpoints.getGrades(model.iss!), forceCache);
var items = List<Grade>.empty(growable: true);
String? err;
try {
List<dynamic> rawItems = resp;
for (var item in rawItems) {
items.add(Grade.fromJson(item));
}
} catch (ex) {
err = ex.toString();
}
if (ex != null) {
err = ex.toString();
}
items.sort((a, b) => b.recordDate.compareTo(a.recordDate));
if (ex == null) gradeCache = ApiResponse(items, 200, null, true);
return ApiResponse(items, status, err, cached);
}
Future<(List<dynamic>, int, Object?, bool)>
_timedCachingGet<T extends DatedCacheEntry>(
IsarCollection<T> cacheModel,
String endpoint,
DateTime from,
DateTime? to,
bool forceCache,
Future<void> Function(dynamic, int) storeCache) async {
var cacheKey = genCacheKey(from, model.studentIdNorm!);
var cache = await cacheModel.get(cacheKey);
var formatter = DateFormat('yyyy-MM-dd');
var fromStr = formatter.format(from);
var toStr = to != null ? formatter.format(to) : null;
var now = timeNow();
if (cache != null && (cache as dynamic).values == null) {
(cache as dynamic).values = List<String>.empty(growable: true);
}
List<dynamic> resp;
int statusCode;
try {
if (forceCache && cache != null) {
var items = List<dynamic>.empty(growable: true);
for (var item in (cache as dynamic).values) {
items.add(jsonDecode(item));
}
return (items, 200, null, true);
}
if (toStr == null) {
(resp, statusCode) = await _authJson(
"GET",
"$endpoint?"
"datumTol=$fromStr");
} else {
(resp, statusCode) = await _authJson(
"GET",
"$endpoint?"
"datumTol=$fromStr&datumIg=$toStr");
}
if (statusCode >= 400) {
if (cache != null) {
var items = List<dynamic>.empty(growable: true);
for (var item in (cache as dynamic).values) {
items.add(jsonDecode(item));
}
return (items, statusCode, null, true);
}
}
} catch (ex) {
if (cache != null) {
var items = List<dynamic>.empty(growable: true);
for (var item in (cache as dynamic).values) {
items.add(jsonDecode(item));
}
return (items, 0, ex, true);
} else {
return (List<dynamic>.empty(growable: true), 0, ex, false);
}
}
// only cache stuff in a 1 month frame
if (from.millisecondsSinceEpoch >=
now.subtract(Duration(days: 30)).millisecondsSinceEpoch) {
if (to == null ||
to.millisecondsSinceEpoch <=
now.add(Duration(days: 30)).millisecondsSinceEpoch) {
await isar.writeTxn(() async {
await storeCache(resp, cacheKey);
});
}
}
return (resp, statusCode, null, false);
}
/// Expects from and to to be 7 days apart
Future<ApiResponse<List<Lesson>>> _getTimeTable(
DateTime from, DateTime to, bool forceCache) async {
var (resp, status, ex, cached) =
await _timedCachingGet<TimetableCacheModel>(
isar.timetableCacheModels,
KretaEndpoints.getTimeTable(model.iss!),
from,
to,
forceCache, (dynamic resp, int cacheKey) async {
TimetableCacheModel cache = TimetableCacheModel();
var rawClasses = List<String>.empty(growable: true);
for (var obj in resp) {
rawClasses.add(jsonEncode(obj));
}
cache.cacheKey = cacheKey;
cache.values = rawClasses;
await isar.timetableCacheModels.put(cache as dynamic);
});
var items = List<Lesson>.empty(growable: true);
String? err;
try {
List<dynamic> rawItems = resp;
for (var item in rawItems) {
items.add(Lesson.fromJson(item));
}
} catch (ex) {
err = ex.toString();
}
if (ex != null) {
err = ex.toString();
}
return ApiResponse(items, status, err, cached);
}
/// Expects from and to to be 7 days apart
Future<ApiResponse<List<Homework>>> _getHomework(
DateTime from, DateTime to, bool forceCache) async {
var (resp, status, ex, cached) = await _timedCachingGet<HomeworkCacheModel>(
isar.homeworkCacheModels,
KretaEndpoints.getHomework(model.iss!),
from,
null,
forceCache, (dynamic resp, int cacheKey) async {
HomeworkCacheModel cache = HomeworkCacheModel();
var rawClasses = List<String>.empty(growable: true);
for (var obj in resp) {
rawClasses.add(jsonEncode(obj));
}
cache.cacheKey = cacheKey;
cache.values = rawClasses;
await isar.homeworkCacheModels.put(cache as dynamic);
});
var items = List<Homework>.empty(growable: true);
String? err;
try {
List<dynamic> rawItems = resp;
for (var item in rawItems) {
items.add(Homework.fromJson(item));
}
} catch (ex) {
err = ex.toString();
}
if (ex != null) {
err = ex.toString();
}
return ApiResponse(items, status, err, cached);
}
/// Automatically aligns requests to start at Monday and end at Sunday
Future<ApiResponse<List<Homework>>> getHomework(DateTime from, DateTime to,
{bool forceCache = true}) async {
var homework = List<Homework>.empty(growable: true);
String? err;
bool cached = true;
for (var i = from.millisecondsSinceEpoch;
i < to.millisecondsSinceEpoch;
i += 604800000) {
var from = DateTime.fromMillisecondsSinceEpoch(i);
var start = from.subtract(Duration(days: from.weekday - 1));
var end = start.add(Duration(days: 6));
var resp = await _getHomework(start, end, forceCache);
if (resp.err != null) {
err = resp.err;
if (!resp.cached) {
return resp;
} else {
homework.addAll(resp.response!);
}
} else {
homework.addAll(resp.response!);
}
if (!resp.cached) cached = false;
}
homework.sort((a, b) => a.startDate.compareTo(b.startDate));
homework = homework.where((h) => h.dueDate.isAfter(timeNow())).toList();
return ApiResponse(homework, 200, err, cached);
}
/// Automatically aligns requests to start at Monday and end at Sunday
Future<ApiResponse<List<Lesson>>> getTimeTable(DateTime from, DateTime to,
{bool forceCache = true}) async {
var lessons = List<Lesson>.empty(growable: true);
String? err;
bool cached = true;
for (var i = from.millisecondsSinceEpoch;
i < to.millisecondsSinceEpoch;
i += 604800000) {
var from = DateTime.fromMillisecondsSinceEpoch(i);
var start = from.subtract(Duration(days: from.weekday - 1));
var end = start.add(Duration(days: 6));
var resp = await _getTimeTable(start, end, forceCache);
if (resp.err != null) {
err = resp.err;
if (!resp.cached) {
return resp;
} else {
lessons.addAll(resp.response!);
}
} else {
lessons.addAll(resp.response!);
}
if (!resp.cached) cached = false;
}
lessons.sort((a, b) => a.start.compareTo(b.start));
lessons = lessons
.where(
(lesson) => lesson.start.isAfter(from) && lesson.end.isBefore(to))
.toList();
return ApiResponse(lessons, 200, err, cached);
}
Future<ApiResponse<List<Test>>> getTests({bool forceCache = true}) async {
var (resp, status, ex, cached) = await _cachingGet(
CacheId.getTests, KretaEndpoints.getTests(model.iss!), forceCache);
var items = List<Test>.empty(growable: true);
String? err;
try {
List<dynamic> rawItems = resp;
for (var item in rawItems) {
items.add(Test.fromJson(item));
}
} catch (ex) {
err = ex.toString();
}
if (ex != null) {
err = ex.toString();
}
// items.sort((a, b) => a.date.compareTo(b.date));
return ApiResponse(items, status, err, cached);
}
ApiResponse<List<Omission>>? omissionsCache;
Future<ApiResponse<List<Omission>>> getOmissions(
{bool forceCache = true}) async {
if (omissionsCache != null) return omissionsCache!;
var (resp, status, ex, cached) = await _cachingGet(CacheId.getOmissions,
KretaEndpoints.getOmissions(model.iss!), forceCache);
var items = List<Omission>.empty(growable: true);
String? err;
try {
List<dynamic> rawItems = resp;
for (var item in rawItems) {
items.add(Omission.fromJson(item));
}
} catch (ex) {
err = ex.toString();
}
if (ex != null) {
err = ex.toString();
}
items.sort((a, b) => a.date.compareTo(b.date));
if (ex == null) omissionsCache = ApiResponse(items, 200, null, true);
return ApiResponse(items, status, err, cached);
}
void evictMemCache() {
studentCache = null;
noticeBoardCache = null;
gradeCache = null;
omissionsCache = null;
}
}

View File

@@ -16,37 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/// Watch no longer calls Kréta API; data comes from phone via init_data/sync_data.
class Constants {
static const clientId = "kreta-ellenorzo-student-mobile-ios";
}
class KretaEndpoints {
static String kretaBase = "e-kreta.hu";
static String kreta(String iss) {
if (iss == "firka-test") {
return kretaBase;
} else {
return "https://$iss.$kretaBase";
}
}
static String kretaIdp = "https://idp.e-kreta.hu";
static String kretaLoginUrl =
"$kretaIdp/Account/Login?ReturnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Fprompt%3Dlogin%26nonce%3DwylCrqT4oN6PPgQn2yQB0euKei9nJeZ6_ffJ-VpSKZU%26response_type%3Dcode%26code_challenge_method%3DS256%26scope%3Dopenid%2520email%2520offline_access%2520kreta-ellenorzo-webapi.public%2520kreta-eugyintezes-webapi.public%2520kreta-fileservice-webapi.public%2520kreta-mobile-global-webapi.public%2520kreta-dkt-webapi.public%2520kreta-ier-webapi.public%26code_challenge%3DHByZRRnPGb-Ko_wTI7ibIba1HQ6lor0ws4bcgReuYSQ%26redirect_uri%3Dhttps%253A%252F%252Fmobil.e-kreta.hu%252Fellenorzo-student%252Fprod%252Foauthredirect%26client_id%3Dkreta-ellenorzo-student-mobile-ios%26state%3Dkreta_student_mobile%26suppressed_prompt%3Dlogin";
static String tokenGrantUrl = "$kretaIdp/connect/token";
static String getStudentUrl(String iss) =>
"${kreta(iss)}/ellenorzo/v3/sajat/TanuloAdatlap";
static String getNoticeBoard(String iss) =>
"${kreta(iss)}/ellenorzo/v3/sajat/FaliujsagElemek";
static String getGrades(String iss) =>
"${kreta(iss)}/ellenorzo/v3/sajat/Ertekelesek";
static String getTimeTable(String iss) =>
"${kreta(iss)}/ellenorzo/v3/sajat/OrarendElemek";
static String getOmissions(String iss) =>
"${kreta(iss)}/ellenorzo/v3/sajat/Mulasztasok";
static String getHomework(String iss) =>
"${kreta(iss)}/ellenorzo/v3/sajat/HaziFeladatok";
static String getTests(String iss) =>
"${kreta(iss)}/ellenorzo/v3/sajat/BejelentettSzamonkeresek";
}

View File

@@ -1,90 +0,0 @@
/*
Firka, alternative e-Kréta client.
Copyright (C) 2025 QwIT Development
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import 'package:dio/dio.dart';
import 'package:firka_wear/helpers/api/resp/token_grant.dart';
import 'package:firka_wear/helpers/db/models/token_model.dart';
import '../../main.dart';
import 'consts.dart';
Future<TokenGrantResponse> getAccessToken(String code) async {
final headers = const <String, String>{
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"accept": "*/*",
"user-agent": "eKretaStudent/264745 CFNetwork/1494.0.7 Darwin/23.4.0",
};
final formData = <String, String>{
"code": code,
"code_verifier": "DSpuqj_HhDX4wzQIbtn8lr8NLE5wEi1iVLMtMK0jY6c",
"redirect_uri":
"https://mobil.e-kreta.hu/ellenorzo-student/prod/oauthredirect",
"client_id": Constants.clientId,
"grant_type": "authorization_code",
};
try {
final response = await dio.post(KretaEndpoints.tokenGrantUrl,
options: Options(headers: headers), data: formData);
switch (response.statusCode) {
case 200:
return TokenGrantResponse.fromJson(response.data);
case 401:
throw Exception("Invalid grant");
default:
throw Exception(
"Failed to get access token, response code: ${response.statusCode}");
}
} catch (e) {
rethrow;
}
}
Future<TokenGrantResponse> extendToken(TokenModel model) async {
final headers = const <String, String>{
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"accept": "*/*",
"user-agent": "eKretaStudent/264745 CFNetwork/1494.0.7 Darwin/23.4.0",
};
final formData = <String, String>{
"institute_code": model.iss!,
"refresh_token": model.refreshToken!,
"grant_type": "refresh_token",
"client_id": Constants.clientId,
};
try {
final response = await dio.post(KretaEndpoints.tokenGrantUrl,
options: Options(headers: headers), data: formData);
switch (response.statusCode) {
case 200:
return TokenGrantResponse.fromJson(response.data);
case 401:
throw Exception("Invalid grant");
default:
throw Exception(
"Failed to get access token, response code: ${response.statusCode}");
}
} catch (e) {
rethrow;
}
}

View File

@@ -1,10 +1,5 @@
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:firka_wear/helpers/api/resp/token_grant.dart';
import 'package:isar_community/isar.dart';
import '../../api/resp/token_grant.dart';
import '../../debug_helper.dart';
part 'token_model.g.dart';
@collection
@@ -19,8 +14,15 @@ class TokenModel {
TokenModel();
factory TokenModel.fromValues(Id studentIdNorm, studentId, String iss,
String idToken, String accessToken, String refreshToken, int expiryDate) {
factory TokenModel.fromValues(
Id studentIdNorm,
studentId,
String iss,
String idToken,
String accessToken,
String refreshToken,
int expiryDate,
) {
var m = TokenModel();
m.studentIdNorm = studentIdNorm;
@@ -33,22 +35,4 @@ class TokenModel {
return m;
}
factory TokenModel.fromResp(TokenGrantResponse resp) {
var m = TokenModel();
final jwt = JWT.decode(resp.idToken);
m.studentIdNorm = int.parse(
jwt.payload["kreta:user_name"].toString().replaceAll("G0", ""));
m.studentId = jwt.payload["kreta:user_name"];
m.iss = jwt.payload["kreta:institute_code"];
m.idToken = resp.idToken;
m.accessToken = resp.accessToken;
m.refreshToken = resp.refreshToken;
m.expiryDate = timeNow()
.add(Duration(seconds: resp.expiresIn))
.subtract(Duration(minutes: 1)); // just to be safe
return m;
}
}

View File

@@ -0,0 +1,79 @@
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:firka_wear/helpers/api/model/grade.dart';
import 'package:firka_wear/helpers/api/model/timetable.dart';
const String _syncFileName = 'wear_sync_data.json';
/// Persists and loads synced data (timetable, grades, lastSyncAt) from the phone.
class WearSyncStore {
List<Lesson> _timetable = [];
List<Grade> _grades = [];
DateTime? _lastSyncAt;
List<Lesson> get timetable => List.unmodifiable(_timetable);
List<Grade> get grades => List.unmodifiable(_grades);
DateTime? get lastSyncAt => _lastSyncAt;
/// True if we have no data or data is older than 1 hour.
bool get needsSync {
if (_lastSyncAt == null) return true;
return DateTime.now().difference(_lastSyncAt!) > const Duration(hours: 1);
}
Future<String> _getSyncFilePath() async {
final dir = await getApplicationDocumentsDirectory();
return '${dir.path}/$_syncFileName';
}
Future<void> load() async {
try {
final path = await _getSyncFilePath();
final file = File(path);
if (!await file.exists()) return;
final json =
jsonDecode(await file.readAsString()) as Map<String, dynamic>;
_lastSyncAt = json['lastSyncAt'] != null
? DateTime.parse(json['lastSyncAt'] as String)
: null;
final rawTimetable = json['timetable'] as List<dynamic>? ?? [];
_timetable = rawTimetable
.map((e) => Lesson.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
final rawGrades = json['grades'] as List<dynamic>? ?? [];
_grades = rawGrades
.map((e) => Grade.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
} catch (_) {}
}
Future<void> save({
required DateTime? lastSyncAt,
required List<Lesson> timetable,
required List<Grade> grades,
}) async {
_lastSyncAt = lastSyncAt;
_timetable = timetable;
_grades = grades;
final path = await _getSyncFilePath();
final file = File(path);
await file.writeAsString(
jsonEncode({
'lastSyncAt': lastSyncAt?.toUtc().toIso8601String(),
'timetable': timetable.map((e) => e.toJson()).toList(),
'grades': grades.map((e) => e.toJson()).toList(),
}),
);
}
/// Returns lessons that fall on [date] (by date string or start date).
List<Lesson> getLessonsForDate(DateTime date) {
final dateStr =
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
return _timetable.where((l) => l.date == dateStr).toList()
..sort((a, b) => a.start.compareTo(b.start));
}
}

View File

@@ -7,6 +7,7 @@ import 'package:firka_wear/helpers/db/models/generic_cache_model.dart';
import 'package:firka_wear/helpers/db/models/homework_cache_model.dart';
import 'package:firka_wear/helpers/db/models/timetable_cache_model.dart';
import 'package:firka_wear/helpers/db/models/token_model.dart';
import 'package:firka_wear/helpers/sync/wear_sync_store.dart';
import 'package:firka_wear/ui/model/style.dart';
import 'package:firka_wear/ui/wear/screens/login/login_screen.dart';
import 'package:flutter/material.dart';
@@ -18,7 +19,6 @@ import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:wear_plus/wear_plus.dart';
import 'helpers/api/client/kreta_client.dart';
import 'l10n/app_localizations.dart';
import 'l10n/app_localizations_de.dart';
import 'l10n/app_localizations_en.dart';
@@ -47,16 +47,18 @@ class DeviceInfo {
class WearAppInitialization {
final Isar isar;
late KretaClient client;
final WearSyncStore syncStore;
final int tokenCount;
final AppLocalizations l10n;
final DeviceInfo devInfo;
WearAppInitialization(
{required this.isar,
required this.tokenCount,
required this.l10n,
required this.devInfo});
WearAppInitialization({
required this.isar,
required this.syncStore,
required this.tokenCount,
required this.l10n,
required this.devInfo,
});
}
Future<Isar> initDB() async {
@@ -68,7 +70,7 @@ Future<Isar> initDB() async {
TokenModelSchema,
GenericCacheModelSchema,
TimetableCacheModelSchema,
HomeworkCacheModelSchema
HomeworkCacheModelSchema,
],
inspector: true,
directory: dir.path,
@@ -90,28 +92,21 @@ AppLocalizations getLang() {
Future<WearAppInitialization> initializeApp() async {
final isar = await initDB();
final syncStore = WearSyncStore();
await syncStore.load();
const channel = MethodChannel("firka.app/main");
final rawInfo =
((await channel.invokeMethod("get_info")) as String).split(";");
final rawInfo = ((await channel.invokeMethod("get_info")) as String).split(
";",
);
var init = WearAppInitialization(
return WearAppInitialization(
isar: isar,
syncStore: syncStore,
tokenCount: await isar.tokenModels.count(),
l10n: getLang(),
devInfo: DeviceInfo(rawInfo[0], rawInfo[1], rawInfo[2]),
);
resetOldTimeTableCache(isar);
resetOldHomeworkCache(isar);
// TODO: Account selection
if (init.tokenCount > 0) {
init.client =
KretaClient((await isar.tokenModels.where().findFirst())!, isar);
}
return init;
}
void main() async {
@@ -207,7 +202,7 @@ class WearInitializationScreen extends StatelessWidget {
'/login': (context) =>
WearLoginScreen(data, key: ValueKey('wearLoginScreen')),
'/home': (context) =>
WearHomeScreen(data, key: ValueKey('wearHomeScreen'))
WearHomeScreen(data, key: ValueKey('wearHomeScreen')),
},
);
}
@@ -217,11 +212,7 @@ class WearInitializationScreen extends StatelessWidget {
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
color: wearStyle.colors.secondary,
)
],
children: [Container(color: wearStyle.colors.secondary)],
),
),
),

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:math';
import 'package:firka_wear/helpers/api/model/grade.dart';
import 'package:firka_wear/helpers/api/model/timetable.dart';
import 'package:firka_wear/helpers/extensions.dart';
import 'package:firka_wear/ui/widget/class_icon.dart';
@@ -9,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_arc_text/flutter_arc_text.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:watch_connectivity/watch_connectivity.dart';
import 'package:wear_plus/wear_plus.dart';
import '../../../../helpers/debug_helper.dart';
@@ -38,6 +40,8 @@ class _WearHomeScreenState extends State<WearHomeScreen> {
bool init = false;
WearMode mode = WearMode.active;
final platform = MethodChannel('firka.app/main');
final watch = WatchConnectivity();
StreamSubscription? _messageSub;
bool disposed = false;
@@ -45,7 +49,10 @@ class _WearHomeScreenState extends State<WearHomeScreen> {
void initState() {
super.initState();
now = timeNow();
_messageSub = watch.messageStream.listen((e) {
final msg = Map<String, dynamic>.from(e);
if (msg['id'] == 'sync_data') _onSyncData(msg);
});
timer = Timer.periodic(Duration(seconds: 1), (timer) async {
setState(() {
now = timeNow();
@@ -54,22 +61,40 @@ class _WearHomeScreenState extends State<WearHomeScreen> {
initStateAsync();
}
Future<void> initStateAsync() async {
var kreta = data.client;
now = timeNow();
var todayStart = now.getMidnight();
var todayEnd = todayStart.add(Duration(hours: 23, minutes: 59));
var classes = await kreta.getTimeTable(todayStart, todayEnd);
void _onSyncData(Map<String, dynamic> msg) async {
final lastSyncAt = msg['lastSyncAt'] != null
? DateTime.parse(msg['lastSyncAt'] as String)
: null;
final rawTimetable = msg['timetable'] as List<dynamic>? ?? [];
final timetable = rawTimetable
.map((e) => Lesson.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
final rawGrades = msg['grades'] as List<dynamic>? ?? [];
final grades = rawGrades
.map((e) => Grade.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
await data.syncStore.save(
lastSyncAt: lastSyncAt,
timetable: timetable,
grades: grades,
);
if (disposed) return;
setState(() {
if (classes.response != null) today = classes.response!;
if (classes.statusCode != 200) {
apiError = "Unexpected status : ${classes.statusCode}";
}
if (classes.err != null) apiError = classes.err!;
now = timeNow();
today = data.syncStore.getLessonsForDate(now);
});
}
Future<void> initStateAsync() async {
now = timeNow();
if (data.syncStore.needsSync) {
watch.sendMessage({'id': 'request_sync'});
}
await data.syncStore.load();
if (disposed) return;
setState(() {
now = timeNow();
today = data.syncStore.getLessonsForDate(now);
init = true;
});
}
@@ -82,34 +107,44 @@ class _WearHomeScreenState extends State<WearHomeScreen> {
return (body, 255.h);
}
if (today.isEmpty && apiError != "") {
body.add(Text(
apiError,
style:
wearStyle.fonts.H_18px.apply(color: wearStyle.colors.textPrimary),
textAlign: TextAlign.center,
));
if (today.isEmpty &&
data.syncStore.needsSync &&
data.syncStore.timetable.isEmpty) {
body.add(
Text(
AppLocalizations.of(context)!.wear_sync_with_phone,
style: wearStyle.fonts.H_18px.apply(
color: wearStyle.colors.textPrimary,
),
textAlign: TextAlign.center,
),
);
return (body, 255.h);
}
if (today.isEmpty) {
body.add(Text(
AppLocalizations.of(context)!.noClasses,
style:
wearStyle.fonts.H_18px.apply(color: wearStyle.colors.textPrimary),
textAlign: TextAlign.center,
));
body.add(
Text(
AppLocalizations.of(context)!.noClasses,
style: wearStyle.fonts.H_18px.apply(
color: wearStyle.colors.textPrimary,
),
textAlign: TextAlign.center,
),
);
platform.invokeMethod('activity_cancel');
return (body, 255.h);
}
if (now.isAfter(today.last.end)) {
body.add(Text(
AppLocalizations.of(context)!.noMoreClasses,
style:
wearStyle.fonts.H_18px.apply(color: wearStyle.colors.textPrimary),
textAlign: TextAlign.center,
));
body.add(
Text(
AppLocalizations.of(context)!.noMoreClasses,
style: wearStyle.fonts.H_18px.apply(
color: wearStyle.colors.textPrimary,
),
textAlign: TextAlign.center,
),
);
platform.invokeMethod('activity_cancel');
return (body, 300.h);
@@ -117,12 +152,15 @@ class _WearHomeScreenState extends State<WearHomeScreen> {
if (now.isBefore(today.first.start)) {
var untilFirst = today.first.start.difference(now);
body.add(Text(
AppLocalizations.of(context)!.firstIn(untilFirst.formatDuration()),
style:
wearStyle.fonts.H_18px.apply(color: wearStyle.colors.textPrimary),
textAlign: TextAlign.center,
));
body.add(
Text(
AppLocalizations.of(context)!.firstIn(untilFirst.formatDuration()),
style: wearStyle.fonts.H_18px.apply(
color: wearStyle.colors.textPrimary,
),
textAlign: TextAlign.center,
),
);
platform.invokeMethod('activity_update');
return (body, 255.h);
@@ -155,14 +193,17 @@ class _WearHomeScreenState extends State<WearHomeScreen> {
var minutes = currentBreakProgress.inMinutes + 1;
body.add(CustomPaint(
body.add(
CustomPaint(
painter: CircularProgressPainter(
progress: currentBreakProgress.inMilliseconds /
currentBreak.inMilliseconds,
// progress: 5 / 10,
screenSize: MediaQuery.of(context).size,
strokeWidth: 4,
color: wearStyle.colors.accent),
progress:
currentBreakProgress.inMilliseconds /
currentBreak.inMilliseconds,
// progress: 5 / 10,
screenSize: MediaQuery.of(context).size,
strokeWidth: 4,
color: wearStyle.colors.accent,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@@ -174,9 +215,7 @@ class _WearHomeScreenState extends State<WearHomeScreen> {
color: wearStyle.colors.textPrimary,
fontSize: 14,
fontFamily: 'Montserrat',
fontVariations: [
FontVariation('wght', 600),
],
fontVariations: [FontVariation('wght', 600)],
),
),
),
@@ -187,14 +226,14 @@ class _WearHomeScreenState extends State<WearHomeScreen> {
color: wearStyle.colors.textPrimary,
fontSize: 12,
fontFamily: 'Montserrat',
fontVariations: [
FontVariation('wght', 400),
],
fontVariations: [FontVariation('wght', 400)],
),
),
)
),
],
)));
),
),
);
platform.invokeMethod('activity_update');
return (body, 200.h);
@@ -215,61 +254,61 @@ class _WearHomeScreenState extends State<WearHomeScreen> {
color: wearStyle.colors.textPrimary,
fontSize: 12,
fontFamily: 'Montserrat',
fontVariations: [
FontVariation('wght', 400),
],
fontVariations: [FontVariation('wght', 400)],
),
),
);
}
body.add(CustomPaint(
body.add(
CustomPaint(
painter: CircularProgressPainter(
progress: elapsed.inMilliseconds / duration.inMilliseconds,
screenSize: MediaQuery.of(context).size,
strokeWidth: 4,
color: wearStyle.colors.accent),
child: Column(children: [
SizedBox(height: nextLesson == null ? 20.h : 0),
Center(
child: ClassIconWidget(
color: wearStyle.colors.accent,
size: 16,
uid: currentLesson.uid,
className: currentLesson.name,
category: currentLesson.subject?.name ?? '',
).build(context),
),
const SizedBox(height: 4),
Center(
child: Text(
"${currentLesson.name}, ${currentLesson.roomName}",
style: TextStyle(
color: wearStyle.colors.textPrimary,
fontSize: 14,
fontFamily: 'Montserrat',
fontVariations: [
FontVariation('wght', 600),
],
progress: elapsed.inMilliseconds / duration.inMilliseconds,
screenSize: MediaQuery.of(context).size,
strokeWidth: 4,
color: wearStyle.colors.accent,
),
child: Column(
children: [
SizedBox(height: nextLesson == null ? 20.h : 0),
Center(
child: ClassIconWidget(
color: wearStyle.colors.accent,
size: 16,
uid: currentLesson.uid,
className: currentLesson.name,
category: currentLesson.subject?.name ?? '',
).build(context),
),
const SizedBox(height: 4),
Center(
child: Text(
"${currentLesson.name}, ${currentLesson.roomName}",
style: TextStyle(
color: wearStyle.colors.textPrimary,
fontSize: 14,
fontFamily: 'Montserrat',
fontVariations: [FontVariation('wght', 600)],
),
),
),
),
Center(
child: Text(
AppLocalizations.of(context)!.timeLeft(minutes),
style: TextStyle(
color: wearStyle.colors.textPrimary,
fontSize: 12,
fontFamily: 'Montserrat',
fontVariations: [
FontVariation('wght', 400),
],
Center(
child: Text(
AppLocalizations.of(context)!.timeLeft(minutes),
style: TextStyle(
color: wearStyle.colors.textPrimary,
fontSize: 12,
fontFamily: 'Montserrat',
fontVariations: [FontVariation('wght', 400)],
),
),
),
),
const SizedBox(height: 8),
nextLessonWidget,
])));
const SizedBox(height: 8),
nextLessonWidget,
],
),
),
);
platform.invokeMethod('activity_update');
return (body, 200.h);
@@ -294,9 +333,7 @@ class _WearHomeScreenState extends State<WearHomeScreen> {
fontSize: 12,
color: wearStyle.colors.secondary,
fontFamily: 'Montserrat',
fontVariations: [
FontVariation('wght', 500),
],
fontVariations: [FontVariation('wght', 500)],
),
placement: Placement.inside,
);
@@ -308,9 +345,7 @@ class _WearHomeScreenState extends State<WearHomeScreen> {
: wearStyle.colors.backgroundAmoled,
body: Stack(
children: [
Center(
child: titleBar,
),
Center(child: titleBar),
Center(
child: Column(
children: [
@@ -318,9 +353,7 @@ class _WearHomeScreenState extends State<WearHomeScreen> {
builder: (context, shape, child) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
child!,
],
children: <Widget>[child!],
);
},
child: AmbientMode(
@@ -339,19 +372,20 @@ class _WearHomeScreenState extends State<WearHomeScreen> {
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Container(
padding: EdgeInsets.only(top: padding),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [...body],
)),
padding: EdgeInsets.only(top: padding),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [...body],
),
),
],
);
},
),
)
),
],
),
)
),
],
),
);
@@ -359,8 +393,9 @@ class _WearHomeScreenState extends State<WearHomeScreen> {
@override
void dispose() {
super.dispose();
_messageSub?.cancel();
timer?.cancel();
disposed = true;
super.dispose();
}
}

View File

@@ -2,7 +2,8 @@
import 'dart:async';
import 'package:firka_wear/helpers/api/client/kreta_client.dart';
import 'package:firka_wear/helpers/api/model/grade.dart';
import 'package:firka_wear/helpers/api/model/timetable.dart';
import 'package:firka_wear/helpers/extensions.dart';
import 'package:flutter/material.dart';
import 'package:watch_connectivity/watch_connectivity.dart';
@@ -47,26 +48,46 @@ class _WearLoginScreen extends State<WearLoginScreen> {
case "init_data":
{
() async {
var data = msg["auth"];
var tokenModel = TokenModel.fromValues(
data["studentIdNorm"],
data["studentId"],
data["iss"],
data["idToken"],
data["accessToken"],
data["refreshToken"],
data["expiryDate"]);
initData.client = KretaClient(tokenModel, initData.isar);
final auth = msg["auth"] as Map<dynamic, dynamic>?;
if (auth == null) return;
final tokenModel = TokenModel.fromValues(
auth["studentIdNorm"] as int,
auth["studentId"] as String,
auth["iss"] as String,
auth["idToken"] as String,
auth["accessToken"] as String,
auth["refreshToken"] as String,
auth["expiryDate"] as int,
);
await initData.isar.writeTxn(() async {
await initData.isar.tokenModels.put(tokenModel);
});
final lastSyncAt = msg["lastSyncAt"] != null
? DateTime.parse(msg["lastSyncAt"] as String)
: null;
final rawTimetable = msg["timetable"] as List<dynamic>? ?? [];
final timetable = rawTimetable
.map(
(e) => Lesson.fromJson(Map<String, dynamic>.from(e as Map)),
)
.toList();
final rawGrades = msg["grades"] as List<dynamic>? ?? [];
final grades = rawGrades
.map(
(e) => Grade.fromJson(Map<String, dynamic>.from(e as Map)),
)
.toList();
await initData.syncStore.save(
lastSyncAt: lastSyncAt,
timetable: timetable,
grades: grades,
);
if (!context.mounted) return;
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (context) => WearHomeScreen(initData)),
(route) => false, // Remove all previous routes
builder: (context) => WearHomeScreen(initData),
),
(route) => false,
);
}();
}
@@ -94,12 +115,7 @@ class _WearLoginScreen extends State<WearLoginScreen> {
(List<Widget>, double) buildBody(BuildContext context) {
if (!init) {
return (
<Widget>[
],
60
);
return (<Widget>[], 60);
}
if (!isPaired) {
@@ -108,11 +124,12 @@ class _WearLoginScreen extends State<WearLoginScreen> {
Text(
widget.data.l10n.wear_phone_unpaired,
textAlign: TextAlign.center,
style: wearStyle.fonts.B_14R
.apply(color: wearStyle.colors.textPrimary),
style: wearStyle.fonts.B_14R.apply(
color: wearStyle.colors.textPrimary,
),
),
],
60
60,
);
}
if (!isReachable) {
@@ -121,11 +138,12 @@ class _WearLoginScreen extends State<WearLoginScreen> {
Text(
widget.data.l10n.wear_phone_disconnected,
textAlign: TextAlign.center,
style: wearStyle.fonts.B_16R
.apply(color: wearStyle.colors.textPrimary),
style: wearStyle.fonts.B_16R.apply(
color: wearStyle.colors.textPrimary,
),
),
],
60
60,
);
}
@@ -135,13 +153,17 @@ class _WearLoginScreen extends State<WearLoginScreen> {
Text(
widget.data.l10n.wear_pairing_request_sent,
textAlign: TextAlign.center,
style: wearStyle.fonts.B_16R
.apply(color: wearStyle.colors.textPrimary),
style: wearStyle.fonts.B_16R.apply(
color: wearStyle.colors.textPrimary,
),
),
ElevatedButton(
onPressed: () async {
debugPrint("[Watch -> Phone]: ping");
watch.sendMessage({'id': 'ping', 'model': initData.devInfo.model});
watch.sendMessage({
'id': 'ping',
'model': initData.devInfo.model,
});
},
// TODO: This is a placeholder, style this properly
style: ButtonStyle(
@@ -158,12 +180,14 @@ class _WearLoginScreen extends State<WearLoginScreen> {
return wearStyle.colors.accent;
}),
),
child: Text(widget.data.l10n.wear_try_again,
textAlign: TextAlign.center,
style: TextStyle(color: wearStyle.colors.textPrimary)),
child: Text(
widget.data.l10n.wear_try_again,
textAlign: TextAlign.center,
style: TextStyle(color: wearStyle.colors.textPrimary),
),
),
],
45
45,
);
}
@@ -173,8 +197,9 @@ class _WearLoginScreen extends State<WearLoginScreen> {
Text(
widget.data.l10n.wear_pairing_check_phone,
textAlign: TextAlign.center,
style: wearStyle.fonts.B_16R
.apply(color: wearStyle.colors.textPrimary),
style: wearStyle.fonts.B_16R.apply(
color: wearStyle.colors.textPrimary,
),
),
ElevatedButton(
onPressed: () async {
@@ -196,22 +221,26 @@ class _WearLoginScreen extends State<WearLoginScreen> {
return wearStyle.colors.accent;
}),
),
child: Text(widget.data.l10n.wear_try_again,
textAlign: TextAlign.center,
style: TextStyle(color: wearStyle.colors.textPrimary)),
child: Text(
widget.data.l10n.wear_try_again,
textAlign: TextAlign.center,
style: TextStyle(color: wearStyle.colors.textPrimary),
),
),
],
55
55,
);
}
return (
<Widget>[
Text("Unexpected state",
style: TextStyle(color: wearStyle.colors.textPrimary, fontSize: 18),
textAlign: TextAlign.center),
Text(
"Unexpected state",
style: TextStyle(color: wearStyle.colors.textPrimary, fontSize: 18),
textAlign: TextAlign.center,
),
],
60
60,
);
}
@@ -225,26 +254,28 @@ class _WearLoginScreen extends State<WearLoginScreen> {
child: Column(
children: [
WatchShape(
builder: (context, shape, child) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Container(
padding: EdgeInsets.only(top: offset),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: body,
)),
],
),
child!,
],
);
},
child: SizedBox())
builder: (context, shape, child) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Container(
padding: EdgeInsets.only(top: offset),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: body,
),
),
],
),
child!,
],
);
},
child: SizedBox(),
),
],
),
),