From 59b470a64c82feb96f275f6921e9dc5c31a37691 Mon Sep 17 00:00:00 2001 From: Armand <4831c0@proton.me> Date: Sun, 1 Mar 2026 12:53:48 +0100 Subject: [PATCH] firka_wear: local sync store, 1h rule, remove KretaClient; data from phone only --- .../lib/helpers/api/client/kreta_client.dart | 519 ------------------ firka_wear/lib/helpers/api/consts.dart | 32 +- firka_wear/lib/helpers/api/token_grant.dart | 90 --- .../lib/helpers/db/models/token_model.dart | 34 +- .../lib/helpers/sync/wear_sync_store.dart | 79 +++ firka_wear/lib/main.dart | 47 +- .../lib/ui/wear/screens/home/home_screen.dart | 269 +++++---- .../ui/wear/screens/login/login_screen.dart | 161 +++--- 8 files changed, 356 insertions(+), 875 deletions(-) delete mode 100644 firka_wear/lib/helpers/api/client/kreta_client.dart delete mode 100644 firka_wear/lib/helpers/api/token_grant.dart create mode 100644 firka_wear/lib/helpers/sync/wear_sync_store.dart diff --git a/firka_wear/lib/helpers/api/client/kreta_client.dart b/firka_wear/lib/helpers/api/client/kreta_client.dart deleted file mode 100644 index 7fc94267..00000000 --- a/firka_wear/lib/helpers/api/client/kreta_client.dart +++ /dev/null @@ -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? 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 _mutexCallback(Future Function() callback) async { - while (_tokenMutex) { - await Future.delayed(const Duration(milliseconds: 50)); - } - _tokenMutex = true; - try { - return callback(); - } finally { - _tokenMutex = false; - } - } - - Future _authReq(String method, String url, [Object? data]) async { - var localToken = await _mutexCallback(() 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 = { - // "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? studentCache; - - Future> 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>? noticeBoardCache; - - Future>> 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.empty(growable: true); - String? err; - try { - List 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>? gradeCache; - - Future>> 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.empty(growable: true); - String? err; - try { - List 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, int, Object?, bool)> - _timedCachingGet( - IsarCollection cacheModel, - String endpoint, - DateTime from, - DateTime? to, - bool forceCache, - Future 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.empty(growable: true); - } - - List resp; - int statusCode; - try { - if (forceCache && cache != null) { - var items = List.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.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.empty(growable: true); - for (var item in (cache as dynamic).values) { - items.add(jsonDecode(item)); - } - return (items, 0, ex, true); - } else { - return (List.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>> _getTimeTable( - DateTime from, DateTime to, bool forceCache) async { - var (resp, status, ex, cached) = - await _timedCachingGet( - isar.timetableCacheModels, - KretaEndpoints.getTimeTable(model.iss!), - from, - to, - forceCache, (dynamic resp, int cacheKey) async { - TimetableCacheModel cache = TimetableCacheModel(); - var rawClasses = List.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.empty(growable: true); - String? err; - try { - List 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>> _getHomework( - DateTime from, DateTime to, bool forceCache) async { - var (resp, status, ex, cached) = await _timedCachingGet( - isar.homeworkCacheModels, - KretaEndpoints.getHomework(model.iss!), - from, - null, - forceCache, (dynamic resp, int cacheKey) async { - HomeworkCacheModel cache = HomeworkCacheModel(); - var rawClasses = List.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.empty(growable: true); - String? err; - try { - List 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>> getHomework(DateTime from, DateTime to, - {bool forceCache = true}) async { - var homework = List.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>> getTimeTable(DateTime from, DateTime to, - {bool forceCache = true}) async { - var lessons = List.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>> getTests({bool forceCache = true}) async { - var (resp, status, ex, cached) = await _cachingGet( - CacheId.getTests, KretaEndpoints.getTests(model.iss!), forceCache); - - var items = List.empty(growable: true); - String? err; - try { - List 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>? omissionsCache; - - Future>> 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.empty(growable: true); - String? err; - try { - List 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; - } -} diff --git a/firka_wear/lib/helpers/api/consts.dart b/firka_wear/lib/helpers/api/consts.dart index 2896f576..f12c4626 100644 --- a/firka_wear/lib/helpers/api/consts.dart +++ b/firka_wear/lib/helpers/api/consts.dart @@ -16,37 +16,7 @@ along with this program. If not, see . */ +/// 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"; -} diff --git a/firka_wear/lib/helpers/api/token_grant.dart b/firka_wear/lib/helpers/api/token_grant.dart deleted file mode 100644 index 2885e690..00000000 --- a/firka_wear/lib/helpers/api/token_grant.dart +++ /dev/null @@ -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 . -*/ - -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 getAccessToken(String code) async { - final headers = const { - "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 = { - "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 extendToken(TokenModel model) async { - final headers = const { - "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 = { - "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; - } -} diff --git a/firka_wear/lib/helpers/db/models/token_model.dart b/firka_wear/lib/helpers/db/models/token_model.dart index 89d994a6..95d14234 100644 --- a/firka_wear/lib/helpers/db/models/token_model.dart +++ b/firka_wear/lib/helpers/db/models/token_model.dart @@ -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; - } } diff --git a/firka_wear/lib/helpers/sync/wear_sync_store.dart b/firka_wear/lib/helpers/sync/wear_sync_store.dart new file mode 100644 index 00000000..de533dd2 --- /dev/null +++ b/firka_wear/lib/helpers/sync/wear_sync_store.dart @@ -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 _timetable = []; + List _grades = []; + DateTime? _lastSyncAt; + + List get timetable => List.unmodifiable(_timetable); + List 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 _getSyncFilePath() async { + final dir = await getApplicationDocumentsDirectory(); + return '${dir.path}/$_syncFileName'; + } + + Future load() async { + try { + final path = await _getSyncFilePath(); + final file = File(path); + if (!await file.exists()) return; + final json = + jsonDecode(await file.readAsString()) as Map; + _lastSyncAt = json['lastSyncAt'] != null + ? DateTime.parse(json['lastSyncAt'] as String) + : null; + final rawTimetable = json['timetable'] as List? ?? []; + _timetable = rawTimetable + .map((e) => Lesson.fromJson(Map.from(e as Map))) + .toList(); + final rawGrades = json['grades'] as List? ?? []; + _grades = rawGrades + .map((e) => Grade.fromJson(Map.from(e as Map))) + .toList(); + } catch (_) {} + } + + Future save({ + required DateTime? lastSyncAt, + required List timetable, + required List 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 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)); + } +} diff --git a/firka_wear/lib/main.dart b/firka_wear/lib/main.dart index 7e3d26b5..a5a15e98 100644 --- a/firka_wear/lib/main.dart +++ b/firka_wear/lib/main.dart @@ -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 initDB() async { @@ -68,7 +70,7 @@ Future initDB() async { TokenModelSchema, GenericCacheModelSchema, TimetableCacheModelSchema, - HomeworkCacheModelSchema + HomeworkCacheModelSchema, ], inspector: true, directory: dir.path, @@ -90,28 +92,21 @@ AppLocalizations getLang() { Future 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)], ), ), ), diff --git a/firka_wear/lib/ui/wear/screens/home/home_screen.dart b/firka_wear/lib/ui/wear/screens/home/home_screen.dart index 040c2a97..b46c057f 100644 --- a/firka_wear/lib/ui/wear/screens/home/home_screen.dart +++ b/firka_wear/lib/ui/wear/screens/home/home_screen.dart @@ -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 { 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 { void initState() { super.initState(); now = timeNow(); - + _messageSub = watch.messageStream.listen((e) { + final msg = Map.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 { initStateAsync(); } - Future 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 msg) async { + final lastSyncAt = msg['lastSyncAt'] != null + ? DateTime.parse(msg['lastSyncAt'] as String) + : null; + final rawTimetable = msg['timetable'] as List? ?? []; + final timetable = rawTimetable + .map((e) => Lesson.fromJson(Map.from(e as Map))) + .toList(); + final rawGrades = msg['grades'] as List? ?? []; + final grades = rawGrades + .map((e) => Grade.fromJson(Map.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 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 { 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 { 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 { 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 { color: wearStyle.colors.textPrimary, fontSize: 14, fontFamily: 'Montserrat', - fontVariations: [ - FontVariation('wght', 600), - ], + fontVariations: [FontVariation('wght', 600)], ), ), ), @@ -187,14 +226,14 @@ class _WearHomeScreenState extends State { 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 { 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 { 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 { : wearStyle.colors.backgroundAmoled, body: Stack( children: [ - Center( - child: titleBar, - ), + Center(child: titleBar), Center( child: Column( children: [ @@ -318,9 +353,7 @@ class _WearHomeScreenState extends State { builder: (context, shape, child) { return Column( mainAxisAlignment: MainAxisAlignment.center, - children: [ - child!, - ], + children: [child!], ); }, child: AmbientMode( @@ -339,19 +372,20 @@ class _WearHomeScreenState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ 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 { @override void dispose() { - super.dispose(); + _messageSub?.cancel(); timer?.cancel(); disposed = true; + super.dispose(); } } diff --git a/firka_wear/lib/ui/wear/screens/login/login_screen.dart b/firka_wear/lib/ui/wear/screens/login/login_screen.dart index c4c41d13..b9d86f67 100644 --- a/firka_wear/lib/ui/wear/screens/login/login_screen.dart +++ b/firka_wear/lib/ui/wear/screens/login/login_screen.dart @@ -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 { 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?; + 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? ?? []; + final timetable = rawTimetable + .map( + (e) => Lesson.fromJson(Map.from(e as Map)), + ) + .toList(); + final rawGrades = msg["grades"] as List? ?? []; + final grades = rawGrades + .map( + (e) => Grade.fromJson(Map.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 { (List, double) buildBody(BuildContext context) { if (!init) { - return ( - [ - - ], - 60 - ); + return ([], 60); } if (!isPaired) { @@ -108,11 +124,12 @@ class _WearLoginScreen extends State { 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 { 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 { 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 { 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 { 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 { 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 ( [ - 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 { child: Column( children: [ WatchShape( - builder: (context, shape, child) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - 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: [ + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: EdgeInsets.only(top: offset), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: body, + ), + ), + ], + ), + child!, + ], + ); + }, + child: SizedBox(), + ), ], ), ),