1
0
forked from firka/firka

fix wearos pairing and syncing

This commit is contained in:
2026-03-02 14:50:14 +01:00
parent 9f36569d2a
commit 5d5c3c4c6f
13 changed files with 503 additions and 149 deletions

View File

@@ -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");

View File

@@ -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 {

View File

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

View File

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

View File

@@ -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(