From 5d5c3c4c6fc4a2e4a7a5f2337228a6ec3832dc04 Mon Sep 17 00:00:00 2001 From: Armand <4831c0@proton.me> Date: Mon, 2 Mar 2026 14:50:14 +0100 Subject: [PATCH] fix wearos pairing and syncing --- firka/lib/app/app_state.dart | 3 + firka/lib/app/initialization_screen.dart | 16 ++ firka/lib/l10n | 2 +- firka/lib/services/watch_sync_helper.dart | 16 +- firka/lib/services/wear_sync_background.dart | 5 +- firka/lib/services/wear_sync_helper.dart | 109 +++++++++ .../ui/phone/pages/extras/main_wear_pair.dart | 208 ++++++++++++------ firka_wear/lib/app/initialization.dart | 2 +- .../lib/data/models/generic_cache_model.dart | 10 +- firka_wear/lib/l10n | 2 +- firka_wear/lib/services/wear_sync_store.dart | 129 ++++++++--- .../lib/ui/wear/screens/home/home_screen.dart | 88 ++++++-- .../ui/wear/screens/login/login_screen.dart | 62 +++++- 13 files changed, 503 insertions(+), 149 deletions(-) create mode 100644 firka/lib/services/wear_sync_helper.dart diff --git a/firka/lib/app/app_state.dart b/firka/lib/app/app_state.dart index 0e9b695..e209f05 100644 --- a/firka/lib/app/app_state.dart +++ b/firka/lib/app/app_state.dart @@ -51,6 +51,9 @@ class AppInitialization { late KretaClient client; List tokens; bool hasWatchListener = false; + + /// Set by the wear pairing modal; called when watch sends init_done or sync_done to dismiss the sheet. + void Function()? dismissWearPairingSheet; Uint8List? profilePicture; SettingsStore settings; ThemeCubit? themeCubit; diff --git a/firka/lib/app/initialization_screen.dart b/firka/lib/app/initialization_screen.dart index 2b9316f..51f090e 100644 --- a/firka/lib/app/initialization_screen.dart +++ b/firka/lib/app/initialization_screen.dart @@ -111,6 +111,16 @@ class _InitializationScreenState extends State { } }); } + break; + case "init_done": + case "sync_done": + final ctx = navigatorKey.currentContext; + if (ctx != null && ctx.mounted) { + ScaffoldMessenger.of(ctx).hideCurrentSnackBar(); + } + initData.dismissWearPairingSheet?.call(); + initData.dismissWearPairingSheet = null; + break; } }; @@ -120,6 +130,12 @@ class _InitializationScreenState extends State { if (msg['id'] == 'request_sync' && initDone && isWearOsSupportEnabled()) { + final ctx = navigatorKey.currentContext; + if (ctx != null && ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(initData.l10n.wear_syncing)), + ); + } await WatchSyncHelper.runWearSyncInForeground( initData.client, ); diff --git a/firka/lib/l10n b/firka/lib/l10n index b700334..72b2574 160000 --- a/firka/lib/l10n +++ b/firka/lib/l10n @@ -1 +1 @@ -Subproject commit b7003344bdcd7daf689485891cb7daaeab1a99c9 +Subproject commit 72b257433b67e8b5a69eda118c31184eab047d7e diff --git a/firka/lib/services/watch_sync_helper.dart b/firka/lib/services/watch_sync_helper.dart index 7a50788..812c8d4 100644 --- a/firka/lib/services/watch_sync_helper.dart +++ b/firka/lib/services/watch_sync_helper.dart @@ -589,9 +589,12 @@ class WatchSyncHelper { } /// Send a fire-and-forget message to Watch via WatchSessionManager (iOS) or watch_connectivity (Android). + /// On Android the payload is sent as a JSON string for reliable transport. static Future sendMessageToWatch(Map message) async { if (Platform.isAndroid) { - await _watchConnectivityAndroid.sendMessage(message); + await _watchConnectivityAndroid.sendMessage({ + 'data': jsonEncode(message), + }); return; } if (!Platform.isIOS) return; @@ -669,9 +672,14 @@ class WatchSyncHelper { /// 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), - ); + return _watchConnectivityAndroid.messageStream.map((m) { + final map = Map.from(m); + final data = map['data']; + if (data is String) { + return jsonDecode(data) as Map; + } + return map; + }); } static Future sendTokenToWatch() async { diff --git a/firka/lib/services/wear_sync_background.dart b/firka/lib/services/wear_sync_background.dart index 328e510..719d1cc 100644 --- a/firka/lib/services/wear_sync_background.dart +++ b/firka/lib/services/wear_sync_background.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter/services.dart'; @@ -55,7 +56,9 @@ void wearSyncBackgroundEntrypoint() { if (payload == null) return null; await writeWearSyncCache(cachePath, payload); final wc = WatchConnectivity(); - await wc.sendMessage({'id': 'sync_data', ...payload}); + await wc.sendMessage({ + 'data': jsonEncode({'id': 'sync_data', ...payload}), + }); return true; } catch (e, st) { debugPrint('[WearSyncBackground] Error: $e $st'); diff --git a/firka/lib/services/wear_sync_helper.dart b/firka/lib/services/wear_sync_helper.dart new file mode 100644 index 0000000..b858053 --- /dev/null +++ b/firka/lib/services/wear_sync_helper.dart @@ -0,0 +1,109 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:watch_connectivity/watch_connectivity.dart'; + +import 'package:firka/app/app_state.dart'; +import 'package:firka/api/client/kreta_client.dart'; +import 'package:firka/services/wear_sync_cache.dart'; + +/// Helper for Wear OS ↔ phone communication (Android only). +/// Handles sync service, watch_connectivity messages, and sending data to the watch. +class WearSyncHelper { + WearSyncHelper._(); + + static const _wearSyncChannel = MethodChannel('app.firka/wear_sync'); + static WatchConnectivity? _watchConnectivity; + static WatchConnectivity get _watchConnectivityInstance { + _watchConnectivity ??= WatchConnectivity(); + return _watchConnectivity!; + } + + /// Sends a fire-and-forget message to the Wear OS watch (payload sent as JSON string). + static Future sendMessageToWatch(Map message) async { + if (!Platform.isAndroid) return; + await _watchConnectivityInstance.sendMessage({ + 'data': jsonEncode(message), + }); + } + + /// Starts the Wear sync foreground service (Android only). + static Future startWearSyncService( + String cachePath, + String appDirPath, + ) async { + if (!Platform.isAndroid) return; + await _wearSyncChannel.invokeMethod( + 'startWearSyncService', + {'cachePath': cachePath, 'appDirPath': appDirPath}, + ); + } + + /// Builds fresh sync payload, writes cache, and starts the Wear sync service (Android only). + static Future startWearSyncServiceWithFreshCache( + KretaClient client, + String appDirPath, + ) async { + if (!Platform.isAndroid) return; + final payload = await buildWearSyncPayload(client); + if (payload == null) return; + final path = await getWearSyncCachePath(); + await writeWearSyncCache(path, payload); + await startWearSyncService(path, appDirPath); + } + + /// Sets the method call handler for getLocalizedString (Android). Call once when initData is ready. + static void setWearSyncMethodCallHandler() { + if (!Platform.isAndroid) return; + _wearSyncChannel.setMethodCallHandler((MethodCall call) async { + if (call.method == 'getLocalizedString') { + final key = call.arguments as String?; + return getLocalizedString(key); + } + return null; + }); + } + + /// Returns the localized string for [key] from l10n. Used by Kotlin for notification title/text. + static String? getLocalizedString(String? key) { + if (key == null || !initDone) return null; + switch (key) { + case 'wearSyncNotificationTitle': + return initData.l10n.wearSyncNotificationTitle; + case 'wearSyncNotificationText': + return initData.l10n.wearSyncNotificationText; + default: + return null; + } + } + + /// 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. + static Future runWearSyncInForeground(KretaClient client) async { + if (!Platform.isAndroid) return; + 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 Wear OS watch. Empty when not on Android. + static Stream> get watchMessageStream { + if (!Platform.isAndroid) return const Stream.empty(); + return _watchConnectivityInstance.messageStream.map((m) { + final map = Map.from(m); + final data = map['data']; + if (data is String) { + return jsonDecode(data) as Map; + } + return map; + }); + } +} 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 dfe9d4e..093d534 100644 --- a/firka/lib/ui/phone/pages/extras/main_wear_pair.dart +++ b/firka/lib/ui/phone/pages/extras/main_wear_pair.dart @@ -1,10 +1,10 @@ -import 'package:firka/ui/components/firka_card.dart'; +import 'package:firka/app/app_state.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'; +import 'package:firka/ui/components/firka_card.dart'; import 'package:firka/ui/theme/style.dart'; void showWearBottomSheet( @@ -27,61 +27,145 @@ void showWearBottomSheet( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.47, ), - builder: (BuildContext context) { - return Stack( - children: [ - Positioned.fill( - child: GestureDetector( - onTap: () => Navigator.pop(context), - behavior: HitTestBehavior.opaque, - child: Container(color: Colors.transparent), - ), + builder: (BuildContext context) => + _WearPairSheetContent(data: data, model: model, payload: payload), + ); +} + +class _WearPairSheetContent extends StatefulWidget { + const _WearPairSheetContent({ + required this.data, + required this.model, + required this.payload, + }); + + final AppInitialization data; + final String model; + final Map payload; + + @override + State<_WearPairSheetContent> createState() => _WearPairSheetContentState(); +} + +class _WearPairSheetContentState extends State<_WearPairSheetContent> { + bool _syncing = false; + + @override + void initState() { + super.initState(); + widget.data.dismissWearPairingSheet = () { + if (mounted) Navigator.of(context).pop(); + }; + } + + @override + void dispose() { + widget.data.dismissWearPairingSheet = null; + super.dispose(); + } + + void _onPairTap() { + setState(() => _syncing = true); + final m = widget.data.client.model; + WatchSyncHelper.sendMessageToWatch({ + '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': widget.payload['lastSyncAt'], + 'timetable': widget.payload['timetable'], + 'grades': widget.payload['grades'], + }); + } + + @override + Widget build(BuildContext context) { + final data = widget.data; + final model = widget.model; + + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: () => Navigator.pop(context), + behavior: HitTestBehavior.opaque, + child: Container(color: Colors.transparent), ), - Align( - alignment: Alignment.bottomCenter, - child: Container( - decoration: BoxDecoration( - color: appStyle.colors.card, - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - SvgPicture.asset("assets/images/wear_pair.svg"), - SizedBox(height: 32), - Center( - child: Text( - data.l10n.pairing, - style: appStyle.fonts.H_14px.apply( - color: appStyle.colors.secondary, - ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + decoration: BoxDecoration( + color: appStyle.colors.card, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset("assets/images/wear_pair.svg"), + const SizedBox(height: 32), + Center( + child: Text( + data.l10n.pairing, + style: appStyle.fonts.H_14px.apply( + color: appStyle.colors.secondary, ), ), - Center( + ), + Center( + child: Text( + model, + style: appStyle.fonts.H_H2.apply( + color: appStyle.colors.textPrimary, + ), + ), + ), + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), child: Text( - model, - style: appStyle.fonts.H_H2.apply( + data.l10n.pairing_description, + style: appStyle.fonts.B_16R.apply( color: appStyle.colors.textPrimary, ), + textAlign: TextAlign.center, ), ), - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Text( - data.l10n.pairing_description, - style: appStyle.fonts.B_16R.apply( - color: appStyle.colors.textPrimary, + ), + const SizedBox(height: 32), + if (_syncing) + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + children: [ + const SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator(strokeWidth: 2), ), - textAlign: TextAlign.center, - ), + const SizedBox(height: 12), + Text( + data.l10n.wear_syncing, + style: appStyle.fonts.B_16R.apply( + color: appStyle.colors.textSecondary, + ), + ), + ], ), - ), - SizedBox(height: 32), + ) + else SizedBox( width: MediaQuery.of(context).size.width / 1.1, child: GestureDetector( + onTap: _onPairTap, child: FirkaCard( left: [], center: [ @@ -94,28 +178,10 @@ void showWearBottomSheet( ], color: appStyle.colors.accent, ), - onTap: () { - final m = data.client.model; - WatchSyncHelper.sendMessageToWatch({ - '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'], - }); - }, ), ), - SizedBox(height: 8), + if (!_syncing) ...[ + const SizedBox(height: 8), SizedBox( width: MediaQuery.of(context).size.width / 1.1, child: GestureDetector( @@ -131,18 +197,16 @@ void showWearBottomSheet( ], color: appStyle.colors.buttonSecondaryFill, ), - onTap: () { - Navigator.pop(context); - }, + onTap: () => Navigator.pop(context), ), ), ], - ), + ], ), ), ), - ], - ); - }, - ); + ), + ], + ); + } } diff --git a/firka_wear/lib/app/initialization.dart b/firka_wear/lib/app/initialization.dart index 508697f..f238eb6 100644 --- a/firka_wear/lib/app/initialization.dart +++ b/firka_wear/lib/app/initialization.dart @@ -80,7 +80,7 @@ AppLocalizations getLang() { Future initializeApp() async { final isar = await initDB(); - final syncStore = WearSyncStore(); + final syncStore = WearSyncStore(isar); await syncStore.load(); const channel = MethodChannel("firka.app/main"); diff --git a/firka_wear/lib/data/models/generic_cache_model.dart b/firka_wear/lib/data/models/generic_cache_model.dart index 8d238ba..fb8be76 100644 --- a/firka_wear/lib/data/models/generic_cache_model.dart +++ b/firka_wear/lib/data/models/generic_cache_model.dart @@ -2,7 +2,15 @@ import 'package:isar_community/isar.dart'; part 'generic_cache_model.g.dart'; -enum CacheId { getStudent, getNoticeBoard, getGrades, getOmissions, getTests } +enum CacheId { + getStudent, + getNoticeBoard, + getGrades, + getOmissions, + getTests, + wearSyncMetadata, + wearSyncTimetable, +} @collection class GenericCacheModel { diff --git a/firka_wear/lib/l10n b/firka_wear/lib/l10n index b700334..72b2574 160000 --- a/firka_wear/lib/l10n +++ b/firka_wear/lib/l10n @@ -1 +1 @@ -Subproject commit b7003344bdcd7daf689485891cb7daaeab1a99c9 +Subproject commit 72b257433b67e8b5a69eda118c31184eab047d7e diff --git a/firka_wear/lib/services/wear_sync_store.dart b/firka_wear/lib/services/wear_sync_store.dart index 3146b65..995348c 100644 --- a/firka_wear/lib/services/wear_sync_store.dart +++ b/firka_wear/lib/services/wear_sync_store.dart @@ -1,14 +1,20 @@ import 'dart:convert'; -import 'dart:io'; +import 'dart:math'; -import 'package:path_provider/path_provider.dart'; +import 'package:isar_community/isar.dart'; import 'package:kreta_api/kreta_api.dart'; -const String _syncFileName = 'wear_sync_data.json'; +import 'package:firka_wear/data/models/generic_cache_model.dart'; +import 'package:firka_wear/data/models/token_model.dart'; /// Persists and loads synced data (timetable, grades, lastSyncAt) from the phone. +/// Uses [GenericCacheModel] for metadata, timetable (single JSON blob), and grades. class WearSyncStore { + WearSyncStore(this.isar); + + final Isar isar; + List _timetable = []; List _grades = []; DateTime? _lastSyncAt; @@ -23,29 +29,53 @@ class WearSyncStore { return DateTime.now().difference(_lastSyncAt!) > const Duration(hours: 1); } - Future _getSyncFilePath() async { - final dir = await getApplicationDocumentsDirectory(); - return '${dir.path}/$_syncFileName'; + static int _genericCacheKey(int studentIdNorm, CacheId id) { + return (studentIdNorm + (id.index + 1) * pow(10, 11)) as int; + } + + Future _getStudentIdNorm() async { + final token = await isar.tokenModels.where().findFirst(); + return token?.studentIdNorm; } 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(); + final studentIdNorm = await _getStudentIdNorm(); + if (studentIdNorm == null) return; + + final metadataKey = _genericCacheKey( + studentIdNorm, + CacheId.wearSyncMetadata, + ); + final metadataCache = await isar.genericCacheModels.get(metadataKey); + if (metadataCache?.cacheData != null) { + final meta = + jsonDecode(metadataCache!.cacheData!) as Map; + _lastSyncAt = meta['lastSyncAt'] != null + ? DateTime.parse(meta['lastSyncAt'] as String) + : null; + } + + final timetableKey = _genericCacheKey( + studentIdNorm, + CacheId.wearSyncTimetable, + ); + final timetableCache = await isar.genericCacheModels.get(timetableKey); + if (timetableCache?.cacheData != null) { + final raw = jsonDecode(timetableCache!.cacheData!) as List; + _timetable = raw + .map((e) => Lesson.fromJson(Map.from(e as Map))) + .toList(); + } + + final gradesKey = _genericCacheKey(studentIdNorm, CacheId.getGrades); + final gradesCache = await isar.genericCacheModels.get(gradesKey); + if (gradesCache?.cacheData != null) { + final raw = jsonDecode(gradesCache!.cacheData!) as List; + _grades = raw + .map((e) => Grade.fromJson(Map.from(e as Map))) + .toList(); + } } catch (_) {} } @@ -54,25 +84,54 @@ class WearSyncStore { required List timetable, required List grades, }) async { + final studentIdNorm = await _getStudentIdNorm(); + if (studentIdNorm == null) return; + _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(), - }), - ); + + await isar.writeTxn(() async { + final metadataKey = _genericCacheKey( + studentIdNorm, + CacheId.wearSyncMetadata, + ); + await isar.genericCacheModels.put( + GenericCacheModel() + ..cacheKey = metadataKey + ..cacheData = jsonEncode({ + 'lastSyncAt': lastSyncAt?.toUtc().toIso8601String(), + }), + ); + + final timetableKey = _genericCacheKey( + studentIdNorm, + CacheId.wearSyncTimetable, + ); + await isar.genericCacheModels.put( + GenericCacheModel() + ..cacheKey = timetableKey + ..cacheData = jsonEncode(timetable.map((e) => e.toJson()).toList()), + ); + + final gradesKey = _genericCacheKey(studentIdNorm, CacheId.getGrades); + await isar.genericCacheModels.put( + GenericCacheModel() + ..cacheKey = gradesKey + ..cacheData = jsonEncode(grades.map((e) => e.toJson()).toList()), + ); + }); } - /// Returns lessons that fall on [date] (by date string or start date). + /// Returns lessons that fall on [date] (compare by calendar day via lesson start). 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() + final y = date.year; + final m = date.month; + final d = date.day; + return _timetable + .where((l) => + l.start.year == y && l.start.month == m && l.start.day == d) + .toList() ..sort((a, b) => a.start.compareTo(b.start)); } } 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 d4f92a7..e837dde 100644 --- a/firka_wear/lib/ui/wear/screens/home/home_screen.dart +++ b/firka_wear/lib/ui/wear/screens/home/home_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:math'; import 'package:flutter/material.dart'; @@ -39,6 +40,7 @@ class _WearHomeScreenState extends State { final platform = MethodChannel('firka.app/main'); final watch = WatchConnectivity(); StreamSubscription? _messageSub; + bool _syncing = false; bool disposed = false; @@ -46,8 +48,14 @@ class _WearHomeScreenState extends State { void initState() { super.initState(); now = timeNow(); + today = data.syncStore.getLessonsForDate(now); + init = data.syncStore.timetable.isNotEmpty; _messageSub = watch.messageStream.listen((e) { - final msg = Map.from(e); + final raw = Map.from(e); + final data = raw['data']; + final msg = data is String + ? jsonDecode(data) as Map + : raw; if (msg['id'] == 'sync_data') _onSyncData(msg); }); timer = Timer.periodic(Duration(seconds: 1), (timer) async { @@ -59,33 +67,44 @@ class _WearHomeScreenState extends State { } 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(() { - now = timeNow(); - today = data.syncStore.getLessonsForDate(now); - }); + setState(() => _syncing = true); + try { + 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, + ); + watch.sendMessage({ + 'data': jsonEncode({'id': 'sync_done'}), + }); + if (disposed) return; + setState(() { + now = timeNow(); + today = data.syncStore.getLessonsForDate(now); + }); + } finally { + if (!disposed) setState(() => _syncing = false); + } } Future initStateAsync() async { now = timeNow(); if (data.syncStore.needsSync) { - watch.sendMessage({'id': 'request_sync'}); + watch.sendMessage({ + 'data': jsonEncode({'id': 'request_sync'}), + }); } await data.syncStore.load(); if (disposed) return; @@ -383,6 +402,31 @@ class _WearHomeScreenState extends State { ], ), ), + if (_syncing) + Positioned.fill( + child: Container( + color: wearStyle.colors.background.withValues(alpha: 0.8), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(height: 12.h), + Text( + AppLocalizations.of(context)!.wear_syncing, + style: wearStyle.fonts.B_16R.apply( + color: wearStyle.colors.textPrimary, + ), + ), + ], + ), + ), + ), + ), ], ), ); 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 07d3130..2036205 100644 --- a/firka_wear/lib/ui/wear/screens/login/login_screen.dart +++ b/firka_wear/lib/ui/wear/screens/login/login_screen.dart @@ -1,6 +1,7 @@ // ignore_for_file: avoid_print import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:kreta_api/kreta_api.dart'; @@ -8,7 +9,6 @@ import 'package:watch_connectivity/watch_connectivity.dart'; import 'package:wear_plus/wear_plus.dart'; import 'package:firka_wear/app/initialization.dart'; -import 'package:firka_wear/core/extensions.dart'; import 'package:firka_wear/data/models/token_model.dart'; import 'package:firka_wear/ui/theme/style.dart'; import 'package:firka_wear/ui/wear/screens/home/home_screen.dart'; @@ -29,6 +29,7 @@ class _WearLoginScreen extends State { bool isReachable = false; bool isMessageSending = false; bool isMessageSent = false; + bool isSyncing = false; final watch = WatchConnectivity(); late Timer connectionTimer; @@ -37,15 +38,21 @@ class _WearLoginScreen extends State { super.initState(); watch.messageStream.listen((e) { - var msg = e.entries.toMap(); + final raw = Map.from(e); + final data = raw['data']; + final msg = data is String + ? jsonDecode(data) as Map + : raw; var id = msg["id"]; debugPrint("[Phone -> Watch]: $id"); switch (id) { case "init_data": - { - () async { + () async { + if (!mounted) return; + setState(() => isSyncing = true); + try { final auth = msg["auth"] as Map?; if (auth == null) return; final tokenModel = TokenModel.fromValues( @@ -80,6 +87,9 @@ class _WearLoginScreen extends State { timetable: timetable, grades: grades, ); + watch.sendMessage({ + 'data': jsonEncode({'id': 'init_done'}), + }); if (!mounted) return; Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( @@ -87,8 +97,11 @@ class _WearLoginScreen extends State { ), (route) => false, ); - }(); - } + } finally { + if (mounted) setState(() => isSyncing = false); + } + }(); + break; } }); @@ -100,7 +113,9 @@ class _WearLoginScreen extends State { isMessageSending = true; debugPrint("[Watch -> Phone]: ping"); - watch.sendMessage({'id': 'ping'}); + watch.sendMessage({ + 'data': jsonEncode({'id': 'ping'}), + }); } setState(() { @@ -116,6 +131,27 @@ class _WearLoginScreen extends State { return ([], 60); } + if (isSyncing) { + return ( + [ + const SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(height: 12), + Text( + widget.data.l10n.wear_syncing, + textAlign: TextAlign.center, + style: wearStyle.fonts.B_16R.apply( + color: wearStyle.colors.textPrimary, + ), + ), + ], + 60, + ); + } + if (!isPaired) { return ( [ @@ -158,9 +194,11 @@ class _WearLoginScreen extends State { ElevatedButton( onPressed: () async { debugPrint("[Watch -> Phone]: ping"); - watch.sendMessage({ - 'id': 'ping', - 'model': initData.devInfo.model, + watch.sendMessage({ + 'data': jsonEncode({ + 'id': 'ping', + 'model': initData.devInfo.model, + }), }); }, // TODO: This is a placeholder, style this properly @@ -202,7 +240,9 @@ class _WearLoginScreen extends State { ElevatedButton( onPressed: () async { debugPrint("[Watch -> Phone]: ping"); - watch.sendMessage({'id': 'ping'}); + watch.sendMessage({ + 'data': jsonEncode({'id': 'ping'}), + }); }, // TODO: This is a placeholder, style this properly style: ButtonStyle(