firka: add Wear sync cache, payload, helper and background entrypoint

This commit is contained in:
2026-03-01 12:45:25 +01:00
parent a70457528b
commit eb1312398d
8 changed files with 251 additions and 33 deletions

View File

@@ -66,6 +66,28 @@ class Grade {
);
}
Map<String, dynamic> 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('

View File

@@ -128,7 +128,7 @@ class Lesson {
'Nev': name,
'Oraszam': lessonNumber,
'OraEvesSorszama': lessonSeqNumber,
'OsztalyCsoport': classGroup,
'OsztalyCsoport': classGroup?.toJson(),
'TanarNeve': teacher,
'Tantargy': subject?.toJson(),
'Tema': theme,

View File

@@ -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<InitializationScreen> {
}
}
};
if (Platform.isAndroid) {
WatchSyncHelper.watchMessageStream.listen((msg) async {
if (msg['id'] == 'request_sync' &&
initDone &&
isWearOsSupportEnabled()) {
await WatchSyncHelper.runWearSyncInForeground(
initData.client,
);
}
});
}
}
if (_router == null) {

View File

@@ -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();
}
},
),
}),

View File

@@ -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<void> sendMessageToWatch(Map<String, dynamic> 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<void> startWearSyncService(String cachePath) async {
if (!Platform.isAndroid) return;
await _wearSyncChannel.invokeMethod<void>(
'startWearSyncService',
cachePath,
);
}
/// Stops the Wear sync foreground service (Android only).
static Future<void> stopWearSyncService() async {
if (!Platform.isAndroid) return;
await _wearSyncChannel.invokeMethod<void>('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<void> runWearSyncInForeground(KretaClient client) async {
final payload = await buildWearSyncPayload(client);
if (payload == null) return;
final path = await getWearSyncCachePath();
await writeWearSyncCache(path, payload);
await sendMessageToWatch(<String, dynamic>{'id': 'sync_data', ...payload});
}
/// Stream of messages from the watch (Android: watch_connectivity). Use for request_sync etc.
static Stream<Map<String, dynamic>> get watchMessageStream {
if (!Platform.isAndroid) return const Stream.empty();
return _watchConnectivityAndroid.messageStream.map(
(m) => Map<String, dynamic>.from(m),
);
}
static Future<void> sendTokenToWatch() async {
if (!Platform.isIOS) return;
final watchInstalled = await isWatchAppInstalled();

View File

@@ -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<dynamic, dynamic>?;
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(<String, dynamic>{'id': 'sync_data', ...payload});
return true;
} catch (e, st) {
debugPrint('[WearSyncBackground] Error: $e $st');
return null;
}
});
}

View File

@@ -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<Map<String, dynamic>?> 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 ?? <Lesson>[])
.map((l) => l.toJson())
.toList();
final grades = (gradesResp.response ?? <Grade>[])
.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<String> getWearSyncCachePath() async {
final dir = await getApplicationDocumentsDirectory();
return '${dir.path}/$wearSyncCacheFileName';
}
/// Writes the sync payload to the cache file at [path].
Future<void> writeWearSyncCache(
String path,
Map<String, dynamic> payload,
) async {
final file = File(path);
await file.writeAsString(jsonEncode(payload));
}

View File

@@ -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<Map<String, dynamic>> 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'],
});
},
),