forked from firka/firka
firka: add Wear sync cache, payload, helper and background entrypoint
This commit is contained in:
@@ -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('
|
||||
|
||||
@@ -128,7 +128,7 @@ class Lesson {
|
||||
'Nev': name,
|
||||
'Oraszam': lessonNumber,
|
||||
'OraEvesSorszama': lessonSeqNumber,
|
||||
'OsztalyCsoport': classGroup,
|
||||
'OsztalyCsoport': classGroup?.toJson(),
|
||||
'TanarNeve': teacher,
|
||||
'Tantargy': subject?.toJson(),
|
||||
'Tema': theme,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
|
||||
65
firka/lib/services/wear_sync_background.dart
Normal file
65
firka/lib/services/wear_sync_background.dart
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
71
firka/lib/services/wear_sync_cache.dart
Normal file
71
firka/lib/services/wear_sync_cache.dart
Normal 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));
|
||||
}
|
||||
@@ -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'],
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user