forked from firka/firka
fix wearos pairing and syncing
This commit is contained in:
@@ -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