From eb1312398d6c1fed26cd5855bf10f1a787f36d13 Mon Sep 17 00:00:00 2001 From: Armand <4831c0@proton.me> Date: Sun, 1 Mar 2026 12:45:25 +0100 Subject: [PATCH] firka: add Wear sync cache, payload, helper and background entrypoint --- firka/lib/api/model/grade.dart | 22 ++++++ firka/lib/api/model/timetable.dart | 2 +- firka/lib/app/initialization_screen.dart | 13 ++++ firka/lib/core/settings.dart | 15 +++- firka/lib/services/watch_sync_helper.dart | 51 ++++++++++++- firka/lib/services/wear_sync_background.dart | 65 +++++++++++++++++ firka/lib/services/wear_sync_cache.dart | 71 +++++++++++++++++++ .../ui/phone/pages/extras/main_wear_pair.dart | 45 +++++------- 8 files changed, 251 insertions(+), 33 deletions(-) create mode 100644 firka/lib/services/wear_sync_background.dart create mode 100644 firka/lib/services/wear_sync_cache.dart diff --git a/firka/lib/api/model/grade.dart b/firka/lib/api/model/grade.dart index 4d11d002..2c293c46 100644 --- a/firka/lib/api/model/grade.dart +++ b/firka/lib/api/model/grade.dart @@ -66,6 +66,28 @@ class Grade { ); } + Map toJson() { + return { + 'Uid': uid, + 'RogzitesDatuma': recordDate.toUtc().toIso8601String(), + 'KeszitesDatuma': creationDate.toUtc().toIso8601String(), + 'LattamozasDatuma': ackDate?.toUtc().toIso8601String(), + 'Tantargy': subject.toJson(), + 'Tema': topic, + 'Tipus': type.toJson(), + 'Mod': mode?.toJson(), + 'ErtekFajta': valueType.toJson(), + 'ErtekeloTanarNeve': teacher, + 'Kind': kind, + 'SzamErtek': numericValue, + 'SzovegesErtek': strValue, + 'SulySzazalekErteke': weightPercentage, + 'SzovegesErtekelesRovidNev': shortStrValue, + 'OsztalyCsoport': classGroup != null ? {'Uid': classGroup!.uid} : null, + 'SortIndex': sortIndex, + }; + } + @override String toString() { return 'Grade(' diff --git a/firka/lib/api/model/timetable.dart b/firka/lib/api/model/timetable.dart index 401ad5cf..d9b5c2c3 100644 --- a/firka/lib/api/model/timetable.dart +++ b/firka/lib/api/model/timetable.dart @@ -128,7 +128,7 @@ class Lesson { 'Nev': name, 'Oraszam': lessonNumber, 'OraEvesSorszama': lessonSeqNumber, - 'OsztalyCsoport': classGroup, + 'OsztalyCsoport': classGroup?.toJson(), 'TanarNeve': teacher, 'Tantargy': subject?.toJson(), 'Tema': theme, diff --git a/firka/lib/app/initialization_screen.dart b/firka/lib/app/initialization_screen.dart index 038ea370..a27a2f49 100644 --- a/firka/lib/app/initialization_screen.dart +++ b/firka/lib/app/initialization_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:firka/app/app_state.dart'; import 'package:firka/app/initialization.dart'; import 'package:firka/core/bloc/home_refresh_cubit.dart'; +import 'package:firka/core/settings.dart'; import 'package:firka/core/bloc/profile_picture_cubit.dart'; import 'package:firka/core/bloc/reauth_cubit.dart'; import 'package:firka/core/bloc/settings_cubit.dart'; @@ -99,6 +100,18 @@ class _InitializationScreenState extends State { } } }; + + if (Platform.isAndroid) { + WatchSyncHelper.watchMessageStream.listen((msg) async { + if (msg['id'] == 'request_sync' && + initDone && + isWearOsSupportEnabled()) { + await WatchSyncHelper.runWearSyncInForeground( + initData.client, + ); + } + }); + } } if (_router == null) { diff --git a/firka/lib/core/settings.dart b/firka/lib/core/settings.dart index 7d2c2339..db49b1a3 100644 --- a/firka/lib/core/settings.dart +++ b/firka/lib/core/settings.dart @@ -14,6 +14,8 @@ import 'package:majesticons_flutter/majesticons_flutter.dart'; import 'package:firka/app/app_state.dart'; import 'package:firka/app/initialization.dart'; import 'package:firka/app/initialization_screen.dart'; +import 'package:firka/services/wear_sync_cache.dart'; +import 'package:firka/services/watch_sync_helper.dart'; import 'package:flutter/material.dart'; const bellRing = 1001; @@ -372,7 +374,18 @@ class SettingsStore { false, always, () async { - // Start/stop Wear sync service wired in WearSyncHelper task + if (!Platform.isAndroid) return; + final enabled = isWearOsSupportEnabled(); + if (enabled && initDone) { + final payload = await buildWearSyncPayload(initData.client); + if (payload != null) { + final path = await getWearSyncCachePath(); + await writeWearSyncCache(path, payload); + await WatchSyncHelper.startWearSyncService(path); + } + } else { + await WatchSyncHelper.stopWearSyncService(); + } }, ), }), diff --git a/firka/lib/services/watch_sync_helper.dart b/firka/lib/services/watch_sync_helper.dart index 6d2ef46d..11cd8596 100644 --- a/firka/lib/services/watch_sync_helper.dart +++ b/firka/lib/services/watch_sync_helper.dart @@ -6,15 +6,24 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:isar_community/isar.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:watch_connectivity/watch_connectivity.dart'; import 'package:firka/app/app_state.dart'; import 'package:firka/services/active_account_helper.dart'; +import 'package:firka/services/wear_sync_cache.dart'; import 'package:firka/api/client/kreta_client.dart'; import 'package:firka/data/models/token_model.dart'; -/// Helper class for Watch ↔ iPhone token sync +/// Helper class for Watch ↔ iPhone token sync (iOS) and Wear OS sync (Android). class WatchSyncHelper { static const _watchChannel = MethodChannel('app.firka/watch_sync'); + static const _wearSyncChannel = MethodChannel('app.firka/wear_sync'); + static WatchConnectivity? _androidWatchConnectivity; + static WatchConnectivity get _watchConnectivityAndroid { + _androidWatchConnectivity ??= WatchConnectivity(); + return _androidWatchConnectivity!; + } + static const _leaseOwnerIPhone = 'iphone'; static bool _initialized = false; static bool _watchAppInstalledCache = false; @@ -579,13 +588,49 @@ class WatchSyncHelper { return tokenData; } - /// Send a fire-and-forget message to Watch via WatchSessionManager. - /// Replaces direct watch_connectivity plugin usage to avoid WCSession delegate conflict. + /// Send a fire-and-forget message to Watch via WatchSessionManager (iOS) or watch_connectivity (Android). static Future sendMessageToWatch(Map message) async { + if (Platform.isAndroid) { + await _watchConnectivityAndroid.sendMessage(message); + return; + } if (!Platform.isIOS) return; await _invokeMethodWithTimeout('sendMessageToWatch', message); } + /// Starts the Wear sync foreground service (Android only). Call after writing initial cache. + static Future startWearSyncService(String cachePath) async { + if (!Platform.isAndroid) return; + await _wearSyncChannel.invokeMethod( + 'startWearSyncService', + cachePath, + ); + } + + /// Stops the Wear sync foreground service (Android only). + static Future stopWearSyncService() async { + if (!Platform.isAndroid) return; + await _wearSyncChannel.invokeMethod('stopWearSyncService'); + } + + /// Runs sync in foreground: fetches timetable + grades, writes cache, sends sync_data to watch. + /// Used when app is in foreground and watch sends request_sync (Android) or equivalent. + static Future runWearSyncInForeground(KretaClient client) async { + final payload = await buildWearSyncPayload(client); + if (payload == null) return; + final path = await getWearSyncCachePath(); + await writeWearSyncCache(path, payload); + await sendMessageToWatch({'id': 'sync_data', ...payload}); + } + + /// Stream of messages from the watch (Android: watch_connectivity). Use for request_sync etc. + static Stream> get watchMessageStream { + if (!Platform.isAndroid) return const Stream.empty(); + return _watchConnectivityAndroid.messageStream.map( + (m) => Map.from(m), + ); + } + static Future sendTokenToWatch() async { if (!Platform.isIOS) return; final watchInstalled = await isWatchAppInstalled(); diff --git a/firka/lib/services/wear_sync_background.dart b/firka/lib/services/wear_sync_background.dart new file mode 100644 index 00000000..328e510b --- /dev/null +++ b/firka/lib/services/wear_sync_background.dart @@ -0,0 +1,65 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:isar_community/isar.dart'; +import 'package:watch_connectivity/watch_connectivity.dart'; + +import 'package:firka/api/client/kreta_client.dart'; +import 'package:firka/core/bloc/reauth_cubit.dart'; +import 'package:firka/data/models/app_settings_model.dart'; +import 'package:firka/data/models/generic_cache_model.dart'; +import 'package:firka/data/models/homework_cache_model.dart'; +import 'package:firka/data/models/timetable_cache_model.dart'; +import 'package:firka/data/models/token_model.dart'; +import 'package:firka/services/wear_sync_cache.dart'; + +/// Background isolate entrypoint for Wear sync (Android). +/// Native invokes with MethodCall 'request_sync' and arguments: {cachePath, appDirPath}. +@pragma('vm:entry-point') +void wearSyncBackgroundEntrypoint() { + WidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('app.firka/wear_sync_background'); + channel.setMethodCallHandler((MethodCall call) async { + if (call.method != 'request_sync') return null; + if (!Platform.isAndroid) return null; + final args = call.arguments as Map?; + final cachePath = args?['cachePath'] as String?; + final appDirPath = args?['appDirPath'] as String?; + if (cachePath == null || appDirPath == null) return null; + try { + final isar = await Isar.open([ + TokenModelSchema, + GenericCacheModelSchema, + TimetableCacheModelSchema, + HomeworkCacheModelSchema, + AppSettingsModelSchema, + HomeworkDoneModelSchema, + ], directory: appDirPath); + final tokens = await isar.tokenModels.where().findAll(); + await isar.close(); + if (tokens.isEmpty) return null; + final token = tokens.first; + final isar2 = await Isar.open([ + TokenModelSchema, + GenericCacheModelSchema, + TimetableCacheModelSchema, + HomeworkCacheModelSchema, + AppSettingsModelSchema, + HomeworkDoneModelSchema, + ], directory: appDirPath); + final reauthCubit = ReauthCubit(); + final client = KretaClient(token, isar2, reauthCubit); + final payload = await buildWearSyncPayload(client); + await isar2.close(); + if (payload == null) return null; + await writeWearSyncCache(cachePath, payload); + final wc = WatchConnectivity(); + await wc.sendMessage({'id': 'sync_data', ...payload}); + return true; + } catch (e, st) { + debugPrint('[WearSyncBackground] Error: $e $st'); + return null; + } + }); +} diff --git a/firka/lib/services/wear_sync_cache.dart b/firka/lib/services/wear_sync_cache.dart new file mode 100644 index 00000000..5e81117a --- /dev/null +++ b/firka/lib/services/wear_sync_cache.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path_provider/path_provider.dart' + show getApplicationDocumentsDirectory; + +import 'package:firka/api/client/kreta_client.dart'; +import 'package:firka/api/model/grade.dart'; +import 'package:firka/api/model/timetable.dart'; +import 'package:firka/core/debug_helper.dart'; + +/// File name for the Wear OS sync cache written by the phone (Dart isolate or main). +const String wearSyncCacheFileName = 'wear_sync_cache.json'; + +/// Returns the 2-week range: Monday 00:00 of current week through Sunday 23:59 of next week. +(DateTime start, DateTime end) getWearSyncTimetableRange() { + final now = timeNow(); + final mondayThisWeek = DateTime( + now.year, + now.month, + now.day, + ).subtract(Duration(days: now.weekday - 1)); + final sundayNextWeek = mondayThisWeek + .add(const Duration(days: 14)) + .subtract(const Duration(milliseconds: 1)); + return (mondayThisWeek, sundayNextWeek); +} + +/// Builds the sync payload (same shape as init_data / sync_data): timetable (2 weeks), grades, lastSyncAt. +Future?> buildWearSyncPayload(KretaClient client) async { + final (start, end) = getWearSyncTimetableRange(); + final timetableResp = await client.getTimeTable( + start, + end, + forceCache: false, + ); + if (timetableResp.err != null && timetableResp.response == null) { + return null; + } + final gradesResp = await client.getGrades(forceCache: false); + if (gradesResp.err != null && gradesResp.response == null) { + return null; + } + final now = timeNow(); + final timetable = (timetableResp.response ?? []) + .map((l) => l.toJson()) + .toList(); + final grades = (gradesResp.response ?? []) + .map((g) => g.toJson()) + .toList(); + return { + 'lastSyncAt': now.toUtc().toIso8601String(), + 'timetable': timetable, + 'grades': grades, + }; +} + +/// Returns the full path for the Wear sync cache file. +Future getWearSyncCachePath() async { + final dir = await getApplicationDocumentsDirectory(); + return '${dir.path}/$wearSyncCacheFileName'; +} + +/// Writes the sync payload to the cache file at [path]. +Future writeWearSyncCache( + String path, + Map payload, +) async { + final file = File(path); + await file.writeAsString(jsonEncode(payload)); +} diff --git a/firka/lib/ui/phone/pages/extras/main_wear_pair.dart b/firka/lib/ui/phone/pages/extras/main_wear_pair.dart index 0fcfbbb3..dfe9d4e1 100644 --- a/firka/lib/ui/phone/pages/extras/main_wear_pair.dart +++ b/firka/lib/ui/phone/pages/extras/main_wear_pair.dart @@ -1,6 +1,6 @@ -import 'package:firka/core/debug_helper.dart'; import 'package:firka/ui/components/firka_card.dart'; import 'package:firka/services/watch_sync_helper.dart'; +import 'package:firka/services/wear_sync_cache.dart'; import 'package:firka/app/app_state.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -12,22 +12,10 @@ void showWearBottomSheet( AppInitialization data, String model, ) async { - final timetable = await data.client.getTimeTable( - timeNow(), - timeNow().add(Duration(days: 7)), - ); - - if (timetable.err != null) { - return; - } + final payload = await buildWearSyncPayload(data.client); + if (payload == null) return; if (!context.mounted) return; - List> timetableArray = List.empty(growable: true); - - for (var l in timetable.response!) { - timetableArray.add(l.toJson()); - } - showModalBottomSheet( context: context, elevation: 100, @@ -107,21 +95,22 @@ void showWearBottomSheet( color: appStyle.colors.accent, ), onTap: () { + final m = data.client.model; WatchSyncHelper.sendMessageToWatch({ - "id": "init_data", - "auth": { - "studentId": data.client.model.studentId, - "studentIdNorm": data.client.model.studentIdNorm, - "iss": data.client.model.iss, - "idToken": data.client.model.idToken, - "accessToken": data.client.model.accessToken, - "refreshToken": data.client.model.refreshToken, - "expiryDate": data - .client - .model - .expiryDate! - .millisecondsSinceEpoch, + 'id': 'init_data', + 'auth': { + 'studentId': m.studentId, + 'studentIdNorm': m.studentIdNorm, + 'iss': m.iss, + 'idToken': m.idToken, + 'accessToken': m.accessToken, + 'refreshToken': m.refreshToken, + 'expiryDate': + m.expiryDate!.millisecondsSinceEpoch, }, + 'lastSyncAt': payload['lastSyncAt'], + 'timetable': payload['timetable'], + 'grades': payload['grades'], }); }, ),