forked from firka/firka
fix wearos pairing and syncing
This commit is contained in:
@@ -51,6 +51,9 @@ class AppInitialization {
|
||||
late KretaClient client;
|
||||
List<TokenModel> 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;
|
||||
|
||||
@@ -111,6 +111,16 @@ class _InitializationScreenState extends State<InitializationScreen> {
|
||||
}
|
||||
});
|
||||
}
|
||||
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<InitializationScreen> {
|
||||
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,
|
||||
);
|
||||
|
||||
Submodule firka/lib/l10n updated: b7003344bd...72b257433b
@@ -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<void> sendMessageToWatch(Map<String, dynamic> message) async {
|
||||
if (Platform.isAndroid) {
|
||||
await _watchConnectivityAndroid.sendMessage(message);
|
||||
await _watchConnectivityAndroid.sendMessage(<String, dynamic>{
|
||||
'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<Map<String, dynamic>> get watchMessageStream {
|
||||
if (!Platform.isAndroid) return const Stream.empty();
|
||||
return _watchConnectivityAndroid.messageStream.map(
|
||||
(m) => Map<String, dynamic>.from(m),
|
||||
);
|
||||
return _watchConnectivityAndroid.messageStream.map((m) {
|
||||
final map = Map<String, dynamic>.from(m);
|
||||
final data = map['data'];
|
||||
if (data is String) {
|
||||
return jsonDecode(data) as Map<String, dynamic>;
|
||||
}
|
||||
return map;
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> sendTokenToWatch() async {
|
||||
|
||||
@@ -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(<String, dynamic>{'id': 'sync_data', ...payload});
|
||||
await wc.sendMessage(<String, dynamic>{
|
||||
'data': jsonEncode(<String, dynamic>{'id': 'sync_data', ...payload}),
|
||||
});
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
debugPrint('[WearSyncBackground] Error: $e $st');
|
||||
|
||||
109
firka/lib/services/wear_sync_helper.dart
Normal file
109
firka/lib/services/wear_sync_helper.dart
Normal file
@@ -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<void> sendMessageToWatch(Map<String, dynamic> message) async {
|
||||
if (!Platform.isAndroid) return;
|
||||
await _watchConnectivityInstance.sendMessage(<String, dynamic>{
|
||||
'data': jsonEncode(message),
|
||||
});
|
||||
}
|
||||
|
||||
/// Starts the Wear sync foreground service (Android only).
|
||||
static Future<void> startWearSyncService(
|
||||
String cachePath,
|
||||
String appDirPath,
|
||||
) async {
|
||||
if (!Platform.isAndroid) return;
|
||||
await _wearSyncChannel.invokeMethod<void>(
|
||||
'startWearSyncService',
|
||||
<String, dynamic>{'cachePath': cachePath, 'appDirPath': appDirPath},
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds fresh sync payload, writes cache, and starts the Wear sync service (Android only).
|
||||
static Future<void> 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<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.
|
||||
static Future<void> 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(<String, dynamic>{'id': 'sync_data', ...payload});
|
||||
}
|
||||
|
||||
/// Stream of messages from the Wear OS watch. Empty when not on Android.
|
||||
static Stream<Map<String, dynamic>> get watchMessageStream {
|
||||
if (!Platform.isAndroid) return const Stream.empty();
|
||||
return _watchConnectivityInstance.messageStream.map((m) {
|
||||
final map = Map<String, dynamic>.from(m);
|
||||
final data = map['data'];
|
||||
if (data is String) {
|
||||
return jsonDecode(data) as Map<String, dynamic>;
|
||||
}
|
||||
return map;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ AppLocalizations getLang() {
|
||||
|
||||
Future<WearAppInitialization> initializeApp() async {
|
||||
final isar = await initDB();
|
||||
final syncStore = WearSyncStore();
|
||||
final syncStore = WearSyncStore(isar);
|
||||
await syncStore.load();
|
||||
|
||||
const channel = MethodChannel("firka.app/main");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Submodule firka_wear/lib/l10n updated: b7003344bd...72b257433b
@@ -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<Lesson> _timetable = [];
|
||||
List<Grade> _grades = [];
|
||||
DateTime? _lastSyncAt;
|
||||
@@ -23,29 +29,53 @@ class WearSyncStore {
|
||||
return DateTime.now().difference(_lastSyncAt!) > const Duration(hours: 1);
|
||||
}
|
||||
|
||||
Future<String> _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<int?> _getStudentIdNorm() async {
|
||||
final token = await isar.tokenModels.where().findFirst();
|
||||
return token?.studentIdNorm;
|
||||
}
|
||||
|
||||
Future<void> load() async {
|
||||
try {
|
||||
final path = await _getSyncFilePath();
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return;
|
||||
final json =
|
||||
jsonDecode(await file.readAsString()) as Map<String, dynamic>;
|
||||
_lastSyncAt = json['lastSyncAt'] != null
|
||||
? DateTime.parse(json['lastSyncAt'] as String)
|
||||
: null;
|
||||
final rawTimetable = json['timetable'] as List<dynamic>? ?? [];
|
||||
_timetable = rawTimetable
|
||||
.map((e) => Lesson.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
final rawGrades = json['grades'] as List<dynamic>? ?? [];
|
||||
_grades = rawGrades
|
||||
.map((e) => Grade.fromJson(Map<String, dynamic>.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<String, dynamic>;
|
||||
_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<dynamic>;
|
||||
_timetable = raw
|
||||
.map((e) => Lesson.fromJson(Map<String, dynamic>.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<dynamic>;
|
||||
_grades = raw
|
||||
.map((e) => Grade.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -54,25 +84,54 @@ class WearSyncStore {
|
||||
required List<Lesson> timetable,
|
||||
required List<Grade> 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<Lesson> 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WearHomeScreen> {
|
||||
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<WearHomeScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
now = timeNow();
|
||||
today = data.syncStore.getLessonsForDate(now);
|
||||
init = data.syncStore.timetable.isNotEmpty;
|
||||
_messageSub = watch.messageStream.listen((e) {
|
||||
final msg = Map<String, dynamic>.from(e);
|
||||
final raw = Map<String, dynamic>.from(e);
|
||||
final data = raw['data'];
|
||||
final msg = data is String
|
||||
? jsonDecode(data) as Map<String, dynamic>
|
||||
: raw;
|
||||
if (msg['id'] == 'sync_data') _onSyncData(msg);
|
||||
});
|
||||
timer = Timer.periodic(Duration(seconds: 1), (timer) async {
|
||||
@@ -59,33 +67,44 @@ class _WearHomeScreenState extends State<WearHomeScreen> {
|
||||
}
|
||||
|
||||
void _onSyncData(Map<String, dynamic> msg) async {
|
||||
final lastSyncAt = msg['lastSyncAt'] != null
|
||||
? DateTime.parse(msg['lastSyncAt'] as String)
|
||||
: null;
|
||||
final rawTimetable = msg['timetable'] as List<dynamic>? ?? [];
|
||||
final timetable = rawTimetable
|
||||
.map((e) => Lesson.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
final rawGrades = msg['grades'] as List<dynamic>? ?? [];
|
||||
final grades = rawGrades
|
||||
.map((e) => Grade.fromJson(Map<String, dynamic>.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<dynamic>? ?? [];
|
||||
final timetable = rawTimetable
|
||||
.map((e) => Lesson.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
final rawGrades = msg['grades'] as List<dynamic>? ?? [];
|
||||
final grades = rawGrades
|
||||
.map((e) => Grade.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
await data.syncStore.save(
|
||||
lastSyncAt: lastSyncAt,
|
||||
timetable: timetable,
|
||||
grades: grades,
|
||||
);
|
||||
watch.sendMessage(<String, dynamic>{
|
||||
'data': jsonEncode(<String, dynamic>{'id': 'sync_done'}),
|
||||
});
|
||||
if (disposed) return;
|
||||
setState(() {
|
||||
now = timeNow();
|
||||
today = data.syncStore.getLessonsForDate(now);
|
||||
});
|
||||
} finally {
|
||||
if (!disposed) setState(() => _syncing = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initStateAsync() async {
|
||||
now = timeNow();
|
||||
if (data.syncStore.needsSync) {
|
||||
watch.sendMessage({'id': 'request_sync'});
|
||||
watch.sendMessage(<String, dynamic>{
|
||||
'data': jsonEncode(<String, dynamic>{'id': 'request_sync'}),
|
||||
});
|
||||
}
|
||||
await data.syncStore.load();
|
||||
if (disposed) return;
|
||||
@@ -383,6 +402,31 @@ class _WearHomeScreenState extends State<WearHomeScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<WearLoginScreen> {
|
||||
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<WearLoginScreen> {
|
||||
super.initState();
|
||||
|
||||
watch.messageStream.listen((e) {
|
||||
var msg = e.entries.toMap();
|
||||
final raw = Map<String, dynamic>.from(e);
|
||||
final data = raw['data'];
|
||||
final msg = data is String
|
||||
? jsonDecode(data) as Map<String, dynamic>
|
||||
: 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<dynamic, dynamic>?;
|
||||
if (auth == null) return;
|
||||
final tokenModel = TokenModel.fromValues(
|
||||
@@ -80,6 +87,9 @@ class _WearLoginScreen extends State<WearLoginScreen> {
|
||||
timetable: timetable,
|
||||
grades: grades,
|
||||
);
|
||||
watch.sendMessage(<String, dynamic>{
|
||||
'data': jsonEncode(<String, dynamic>{'id': 'init_done'}),
|
||||
});
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(
|
||||
@@ -87,8 +97,11 @@ class _WearLoginScreen extends State<WearLoginScreen> {
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
}();
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => isSyncing = false);
|
||||
}
|
||||
}();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -100,7 +113,9 @@ class _WearLoginScreen extends State<WearLoginScreen> {
|
||||
isMessageSending = true;
|
||||
|
||||
debugPrint("[Watch -> Phone]: ping");
|
||||
watch.sendMessage({'id': 'ping'});
|
||||
watch.sendMessage(<String, dynamic>{
|
||||
'data': jsonEncode(<String, dynamic>{'id': 'ping'}),
|
||||
});
|
||||
}
|
||||
|
||||
setState(() {
|
||||
@@ -116,6 +131,27 @@ class _WearLoginScreen extends State<WearLoginScreen> {
|
||||
return (<Widget>[], 60);
|
||||
}
|
||||
|
||||
if (isSyncing) {
|
||||
return (
|
||||
<Widget>[
|
||||
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 (
|
||||
<Widget>[
|
||||
@@ -158,9 +194,11 @@ class _WearLoginScreen extends State<WearLoginScreen> {
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
debugPrint("[Watch -> Phone]: ping");
|
||||
watch.sendMessage({
|
||||
'id': 'ping',
|
||||
'model': initData.devInfo.model,
|
||||
watch.sendMessage(<String, dynamic>{
|
||||
'data': jsonEncode(<String, dynamic>{
|
||||
'id': 'ping',
|
||||
'model': initData.devInfo.model,
|
||||
}),
|
||||
});
|
||||
},
|
||||
// TODO: This is a placeholder, style this properly
|
||||
@@ -202,7 +240,9 @@ class _WearLoginScreen extends State<WearLoginScreen> {
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
debugPrint("[Watch -> Phone]: ping");
|
||||
watch.sendMessage({'id': 'ping'});
|
||||
watch.sendMessage(<String, dynamic>{
|
||||
'data': jsonEncode(<String, dynamic>{'id': 'ping'}),
|
||||
});
|
||||
},
|
||||
// TODO: This is a placeholder, style this properly
|
||||
style: ButtonStyle(
|
||||
|
||||
Reference in New Issue
Block a user