feat: the app tells when sync fails, and when it is too old
Published apps can't be made to update, so the app now notices when it no longer matches the server: the server names the oldest app build it works with (instance.info's minimumAppBuild), asked before the first sync of each launch, and the sync engine's refusal of differing synced tables counts the same. Either way the app says "update the app; if it is up to date, the server needs an update" in a banner on Home and in the new Sync card in Settings, and what the person does meanwhile stays on the device. The Sync card shows where sync stands since the app started (syncing, everything synced, sync failing, with the last completion and failure) and offers Sync now. While the last sync failed, a dot sits on the Profile tab and the Settings cog. One sync that goes through clears it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
6c20c44cf5
commit
e35ce5c81d
33 changed files with 1016 additions and 18 deletions
|
|
@ -145,6 +145,8 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Every client migration keeps a device's rows** (`test/core/sync/device_migration_test.dart` replays each on a database with a row in every synced table of ours; SQLite rebuilds tables to add foreign keys, which Serverpod's runner does with foreign keys off). A new synced table needs a row in that test, which fails until it has one.
|
- **Every client migration keeps a device's rows** (`test/core/sync/device_migration_test.dart` replays each on a database with a row in every synced table of ours; SQLite rebuilds tables to add foreign keys, which Serverpod's runner does with foreign keys off). A new synced table needs a row in that test, which fails until it has one.
|
||||||
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
|
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
|
||||||
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
|
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
|
||||||
|
- **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (`core/sync/`), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number.
|
||||||
|
- **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Profile tab and the Settings cog, the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure.
|
||||||
- **Serverpod's version moves together with `serverpod_offline_sync`**, which pins Serverpod exactly: the root `pubspec.yaml` overrides the four core packages to the project's version. Upgrade Serverpod only together with those overrides and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog.
|
- **Serverpod's version moves together with `serverpod_offline_sync`**, which pins Serverpod exactly: the root `pubspec.yaml` overrides the four core packages to the project's version. Upgrade Serverpod only together with those overrides and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog.
|
||||||
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.
|
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.
|
||||||
- **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.
|
- **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,7 @@ class EndpointInstance extends _isc.EndpointRef {
|
||||||
String get name => 'instance';
|
String get name => 'instance';
|
||||||
|
|
||||||
/// This instance's offer: GitHub sign-in only with the operator's OAuth
|
/// This instance's offer: GitHub sign-in only with the operator's OAuth
|
||||||
/// app, and where its source is.
|
/// app, where its source is, and the oldest app build it works with.
|
||||||
_ida.Future<_i26u8vl4.InstanceInfo> info() =>
|
_ida.Future<_i26u8vl4.InstanceInfo> info() =>
|
||||||
caller.callServerEndpoint<_i26u8vl4.InstanceInfo>(
|
caller.callServerEndpoint<_i26u8vl4.InstanceInfo>(
|
||||||
'instance',
|
'instance',
|
||||||
|
|
|
||||||
|
|
@ -19,17 +19,20 @@ abstract class InstanceInfo
|
||||||
InstanceInfo._({
|
InstanceInfo._({
|
||||||
this.gitHubClientId,
|
this.gitHubClientId,
|
||||||
required this.sourceCodeUrl,
|
required this.sourceCodeUrl,
|
||||||
|
this.minimumAppBuild,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory InstanceInfo({
|
factory InstanceInfo({
|
||||||
String? gitHubClientId,
|
String? gitHubClientId,
|
||||||
required String sourceCodeUrl,
|
required String sourceCodeUrl,
|
||||||
|
int? minimumAppBuild,
|
||||||
}) = _InstanceInfoImpl;
|
}) = _InstanceInfoImpl;
|
||||||
|
|
||||||
factory InstanceInfo.fromJson(Map<String, dynamic> jsonSerialization) {
|
factory InstanceInfo.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
return InstanceInfo(
|
return InstanceInfo(
|
||||||
gitHubClientId: jsonSerialization['gitHubClientId'] as String?,
|
gitHubClientId: jsonSerialization['gitHubClientId'] as String?,
|
||||||
sourceCodeUrl: jsonSerialization['sourceCodeUrl'] as String,
|
sourceCodeUrl: jsonSerialization['sourceCodeUrl'] as String,
|
||||||
|
minimumAppBuild: jsonSerialization['minimumAppBuild'] as int?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,12 +44,18 @@ abstract class InstanceInfo
|
||||||
/// offering it to the people who use it.
|
/// offering it to the people who use it.
|
||||||
String sourceCodeUrl;
|
String sourceCodeUrl;
|
||||||
|
|
||||||
|
/// The oldest build of the app (the number after `+` in its version) this
|
||||||
|
/// server still works with; older apps ask to be updated. Null from servers
|
||||||
|
/// that predate it: any build.
|
||||||
|
int? minimumAppBuild;
|
||||||
|
|
||||||
/// Returns a shallow copy of this [InstanceInfo]
|
/// Returns a shallow copy of this [InstanceInfo]
|
||||||
/// with some or all fields replaced by the given arguments.
|
/// with some or all fields replaced by the given arguments.
|
||||||
@_isc.useResult
|
@_isc.useResult
|
||||||
InstanceInfo copyWith({
|
InstanceInfo copyWith({
|
||||||
String? gitHubClientId,
|
String? gitHubClientId,
|
||||||
String? sourceCodeUrl,
|
String? sourceCodeUrl,
|
||||||
|
int? minimumAppBuild,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
|
@ -54,6 +63,7 @@ abstract class InstanceInfo
|
||||||
'__className__': 'InstanceInfo',
|
'__className__': 'InstanceInfo',
|
||||||
if (gitHubClientId != null) 'gitHubClientId': gitHubClientId,
|
if (gitHubClientId != null) 'gitHubClientId': gitHubClientId,
|
||||||
'sourceCodeUrl': sourceCodeUrl,
|
'sourceCodeUrl': sourceCodeUrl,
|
||||||
|
if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,6 +73,7 @@ abstract class InstanceInfo
|
||||||
'__className__': 'InstanceInfo',
|
'__className__': 'InstanceInfo',
|
||||||
if (gitHubClientId != null) 'gitHubClientId': gitHubClientId,
|
if (gitHubClientId != null) 'gitHubClientId': gitHubClientId,
|
||||||
'sourceCodeUrl': sourceCodeUrl,
|
'sourceCodeUrl': sourceCodeUrl,
|
||||||
|
if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,9 +89,11 @@ class _InstanceInfoImpl extends InstanceInfo {
|
||||||
_InstanceInfoImpl({
|
_InstanceInfoImpl({
|
||||||
String? gitHubClientId,
|
String? gitHubClientId,
|
||||||
required String sourceCodeUrl,
|
required String sourceCodeUrl,
|
||||||
|
int? minimumAppBuild,
|
||||||
}) : super._(
|
}) : super._(
|
||||||
gitHubClientId: gitHubClientId,
|
gitHubClientId: gitHubClientId,
|
||||||
sourceCodeUrl: sourceCodeUrl,
|
sourceCodeUrl: sourceCodeUrl,
|
||||||
|
minimumAppBuild: minimumAppBuild,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Returns a shallow copy of this [InstanceInfo]
|
/// Returns a shallow copy of this [InstanceInfo]
|
||||||
|
|
@ -90,12 +103,16 @@ class _InstanceInfoImpl extends InstanceInfo {
|
||||||
InstanceInfo copyWith({
|
InstanceInfo copyWith({
|
||||||
Object? gitHubClientId = _Undefined,
|
Object? gitHubClientId = _Undefined,
|
||||||
String? sourceCodeUrl,
|
String? sourceCodeUrl,
|
||||||
|
Object? minimumAppBuild = _Undefined,
|
||||||
}) {
|
}) {
|
||||||
return InstanceInfo(
|
return InstanceInfo(
|
||||||
gitHubClientId: gitHubClientId is String?
|
gitHubClientId: gitHubClientId is String?
|
||||||
? gitHubClientId
|
? gitHubClientId
|
||||||
: this.gitHubClientId,
|
: this.gitHubClientId,
|
||||||
sourceCodeUrl: sourceCodeUrl ?? this.sourceCodeUrl,
|
sourceCodeUrl: sourceCodeUrl ?? this.sourceCodeUrl,
|
||||||
|
minimumAppBuild: minimumAppBuild is int?
|
||||||
|
? minimumAppBuild
|
||||||
|
: this.minimumAppBuild,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,24 @@
|
||||||
"settingsDeleteTitle": "Delete your account?",
|
"settingsDeleteTitle": "Delete your account?",
|
||||||
"settingsDeleteMessage": "Everything you have, your studysets, cards and reviews, is deleted on the server and on all your devices. This can't be undone.",
|
"settingsDeleteMessage": "Everything you have, your studysets, cards and reviews, is deleted on the server and on all your devices. This can't be undone.",
|
||||||
"settingsAbout": "About Halkyon Learn",
|
"settingsAbout": "About Halkyon Learn",
|
||||||
|
"syncTitle": "Sync",
|
||||||
|
"syncStateSyncing": "Syncing…",
|
||||||
|
"syncStateSynced": "Everything synced",
|
||||||
|
"syncStateNotYet": "Not synced yet",
|
||||||
|
"syncStateFailing": "Sync is failing",
|
||||||
|
"syncStateMismatch": "This app can't sync with {server}",
|
||||||
|
"@syncStateMismatch": {"placeholders": {"server": {"type": "String"}}},
|
||||||
|
"syncMismatchDetail": "Update the app. If it is up to date, the server needs an update. What you do meanwhile stays on this device and syncs afterwards.",
|
||||||
|
"syncLastCompleted": "Last completed at {time}",
|
||||||
|
"@syncLastCompleted": {"placeholders": {"time": {"type": "String"}}},
|
||||||
|
"syncNoRunYet": "No sync has completed since the app started",
|
||||||
|
"syncLastFailed": "Last attempt failed at {time}",
|
||||||
|
"@syncLastFailed": {"placeholders": {"time": {"type": "String"}}},
|
||||||
|
"syncNow": "Sync now",
|
||||||
|
"syncTroubleLabel": "Sync needs attention",
|
||||||
|
"syncMismatchBanner": "This app can't sync with {server} any more. Update it to keep syncing.",
|
||||||
|
"@syncMismatchBanner": {"placeholders": {"server": {"type": "String"}}},
|
||||||
|
"syncMismatchMore": "More",
|
||||||
|
|
||||||
"tagsReadFailed": "Couldn't read the tags.",
|
"tagsReadFailed": "Couldn't read the tags.",
|
||||||
"tagPickerTitle": "Tags",
|
"tagPickerTitle": "Tags",
|
||||||
|
|
|
||||||
|
|
@ -1498,6 +1498,90 @@ abstract class AppLocalizations {
|
||||||
/// **'About Halkyon Learn'**
|
/// **'About Halkyon Learn'**
|
||||||
String get settingsAbout;
|
String get settingsAbout;
|
||||||
|
|
||||||
|
/// No description provided for @syncTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync'**
|
||||||
|
String get syncTitle;
|
||||||
|
|
||||||
|
/// No description provided for @syncStateSyncing.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Syncing…'**
|
||||||
|
String get syncStateSyncing;
|
||||||
|
|
||||||
|
/// No description provided for @syncStateSynced.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Everything synced'**
|
||||||
|
String get syncStateSynced;
|
||||||
|
|
||||||
|
/// No description provided for @syncStateNotYet.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Not synced yet'**
|
||||||
|
String get syncStateNotYet;
|
||||||
|
|
||||||
|
/// No description provided for @syncStateFailing.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync is failing'**
|
||||||
|
String get syncStateFailing;
|
||||||
|
|
||||||
|
/// No description provided for @syncStateMismatch.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This app can\'t sync with {server}'**
|
||||||
|
String syncStateMismatch(String server);
|
||||||
|
|
||||||
|
/// No description provided for @syncMismatchDetail.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Update the app. If it is up to date, the server needs an update. What you do meanwhile stays on this device and syncs afterwards.'**
|
||||||
|
String get syncMismatchDetail;
|
||||||
|
|
||||||
|
/// No description provided for @syncLastCompleted.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Last completed at {time}'**
|
||||||
|
String syncLastCompleted(String time);
|
||||||
|
|
||||||
|
/// No description provided for @syncNoRunYet.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No sync has completed since the app started'**
|
||||||
|
String get syncNoRunYet;
|
||||||
|
|
||||||
|
/// No description provided for @syncLastFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Last attempt failed at {time}'**
|
||||||
|
String syncLastFailed(String time);
|
||||||
|
|
||||||
|
/// No description provided for @syncNow.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync now'**
|
||||||
|
String get syncNow;
|
||||||
|
|
||||||
|
/// No description provided for @syncTroubleLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync needs attention'**
|
||||||
|
String get syncTroubleLabel;
|
||||||
|
|
||||||
|
/// No description provided for @syncMismatchBanner.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This app can\'t sync with {server} any more. Update it to keep syncing.'**
|
||||||
|
String syncMismatchBanner(String server);
|
||||||
|
|
||||||
|
/// No description provided for @syncMismatchMore.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'More'**
|
||||||
|
String get syncMismatchMore;
|
||||||
|
|
||||||
/// No description provided for @tagsReadFailed.
|
/// No description provided for @tagsReadFailed.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -860,6 +860,57 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get settingsAbout => 'About Halkyon Learn';
|
String get settingsAbout => 'About Halkyon Learn';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncTitle => 'Sync';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncStateSyncing => 'Syncing…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncStateSynced => 'Everything synced';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncStateNotYet => 'Not synced yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncStateFailing => 'Sync is failing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncStateMismatch(String server) {
|
||||||
|
return 'This app can\'t sync with $server';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncMismatchDetail =>
|
||||||
|
'Update the app. If it is up to date, the server needs an update. What you do meanwhile stays on this device and syncs afterwards.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncLastCompleted(String time) {
|
||||||
|
return 'Last completed at $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncNoRunYet => 'No sync has completed since the app started';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncLastFailed(String time) {
|
||||||
|
return 'Last attempt failed at $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncNow => 'Sync now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncTroubleLabel => 'Sync needs attention';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncMismatchBanner(String server) {
|
||||||
|
return 'This app can\'t sync with $server any more. Update it to keep syncing.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncMismatchMore => 'More';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tagsReadFailed => 'Couldn\'t read the tags.';
|
String get tagsReadFailed => 'Couldn\'t read the tags.';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/app/app_router.dart';
|
import 'package:halkyon_learn_flutter/src/app/app_router.dart';
|
||||||
|
|
@ -5,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/editor/core_blocks.dart';
|
import 'package:halkyon_learn_flutter/src/core/editor/core_blocks.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
|
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart';
|
import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_button_block.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_button_block.dart';
|
||||||
|
|
@ -63,8 +65,14 @@ class _PersonAppState() extends State<PersonApp> {
|
||||||
builder: (context, child) => MaterialUiCompatibilityBridge(
|
builder: (context, child) => MaterialUiCompatibilityBridge(
|
||||||
child: PersonScope(
|
child: PersonScope(
|
||||||
person: widget.person,
|
person: widget.person,
|
||||||
// The editors know the features' blocks too.
|
// Where sync stands: the trouble dot, Settings and Home show it.
|
||||||
child: EditorBlocksScope(blocks: _editorBlocks, child: child!),
|
child: BlocProvider(
|
||||||
|
create: (context) =>
|
||||||
|
SyncStatusBloc(PersonScope.of(context).sync)
|
||||||
|
..add(const SyncStatusEventStarted()),
|
||||||
|
// The editors know the features' blocks too.
|
||||||
|
child: EditorBlocksScope(blocks: _editorBlocks, child: child!),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import 'package:halkyon_learn_flutter/src/core/asset/data/sync_asset_gateway.dar
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
|
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_folders.dart';
|
import 'package:halkyon_learn_flutter/src/core/dependencies/person_folders.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/files/file_picker.dart';
|
import 'package:halkyon_learn_flutter/src/core/files/file_picker.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/app_compatibility.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
|
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
|
||||||
|
|
@ -44,6 +45,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_reposit
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/sync_tag_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/data/sync_tag_gateway.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
|
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
|
||||||
|
|
@ -59,6 +61,9 @@ typedef OpenPerson = Future<PersonDependencies> Function(SignedIn person);
|
||||||
/// Features get repositories only, never gateways or the Serverpod client.
|
/// Features get repositories only, never gateways or the Serverpod client.
|
||||||
/// Tests build it with fakes (`test/support/test_dependencies.dart`).
|
/// Tests build it with fakes (`test/support/test_dependencies.dart`).
|
||||||
class Dependencies({
|
class Dependencies({
|
||||||
|
/// The server's name as people know it (its host), for messages about it.
|
||||||
|
required final String serverName,
|
||||||
|
|
||||||
/// Who is signed in, and signing in and out.
|
/// Who is signed in, and signing in and out.
|
||||||
required final AuthRepository auth,
|
required final AuthRepository auth,
|
||||||
|
|
||||||
|
|
@ -95,6 +100,13 @@ class Dependencies({
|
||||||
final client = Client(await getServerUrl())
|
final client = Client(await getServerUrl())
|
||||||
..connectivityMonitor = FlutterConnectivityMonitor()
|
..connectivityMonitor = FlutterConnectivityMonitor()
|
||||||
..authSessionManager = FlutterAuthSessionManager();
|
..authSessionManager = FlutterAuthSessionManager();
|
||||||
|
// Whether the server still works with this build: asked before the
|
||||||
|
// first sync of each launch.
|
||||||
|
final compatibility = AppCompatibility(
|
||||||
|
appBuild: int.tryParse((await PackageInfo.fromPlatform()).buildNumber),
|
||||||
|
minimumAppBuild: () async =>
|
||||||
|
(await client.instance.info()).minimumAppBuild,
|
||||||
|
);
|
||||||
// Restores a stored session before anything asks who is signed in;
|
// Restores a stored session before anything asks who is signed in;
|
||||||
// otherwise the first launch logic would sign in a second anonymous person.
|
// otherwise the first launch logic would sign in a second anonymous person.
|
||||||
await client.auth.initialize();
|
await client.auth.initialize();
|
||||||
|
|
@ -112,6 +124,7 @@ class Dependencies({
|
||||||
await PdfrxEngine.initialize();
|
await PdfrxEngine.initialize();
|
||||||
final sounds = AudioplayersSoundPlayer();
|
final sounds = AudioplayersSoundPlayer();
|
||||||
return Dependencies(
|
return Dependencies(
|
||||||
|
serverName: Uri.parse(client.host).host,
|
||||||
auth: AuthRepository(authGateway),
|
auth: AuthRepository(authGateway),
|
||||||
about: AboutRepository(ServerpodAboutGateway(client)),
|
about: AboutRepository(ServerpodAboutGateway(client)),
|
||||||
maps: GeoMapRepository(),
|
maps: GeoMapRepository(),
|
||||||
|
|
@ -130,6 +143,7 @@ class Dependencies({
|
||||||
try {
|
try {
|
||||||
return await _openPerson(
|
return await _openPerson(
|
||||||
client,
|
client,
|
||||||
|
compatibility,
|
||||||
person,
|
person,
|
||||||
await folders.openOnly(person.authUserId),
|
await folders.openOnly(person.authUserId),
|
||||||
onClosed: closed.complete,
|
onClosed: closed.complete,
|
||||||
|
|
@ -158,6 +172,7 @@ class Dependencies({
|
||||||
/// sync, and the repositories; [onClosed] runs once all is closed again.
|
/// sync, and the repositories; [onClosed] runs once all is closed again.
|
||||||
Future<PersonDependencies> _openPerson(
|
Future<PersonDependencies> _openPerson(
|
||||||
Client client,
|
Client client,
|
||||||
|
AppCompatibility compatibility,
|
||||||
SignedIn person,
|
SignedIn person,
|
||||||
Directory folder, {
|
Directory folder, {
|
||||||
required void Function() onClosed,
|
required void Function() onClosed,
|
||||||
|
|
@ -167,9 +182,12 @@ Future<PersonDependencies> _openPerson(
|
||||||
persistentUserId: UuidValue.withValidation(person.authUserId),
|
persistentUserId: UuidValue.withValidation(person.authUserId),
|
||||||
);
|
);
|
||||||
final sync = SyncService(
|
final sync = SyncService(
|
||||||
({required onMerge}) => client.offlineSync.syncOnce(
|
compatibleSync(
|
||||||
db,
|
({required onMerge}) => client.offlineSync.syncOnce(
|
||||||
onMergeSuccess: (space, changes) => onMerge(),
|
db,
|
||||||
|
onMergeSuccess: (space, changes) => onMerge(),
|
||||||
|
),
|
||||||
|
compatibility,
|
||||||
),
|
),
|
||||||
)..start();
|
)..start();
|
||||||
final studysets = StudysetRepository(SyncStudysetGateway(db, sync));
|
final studysets = StudysetRepository(SyncStudysetGateway(db, sync));
|
||||||
|
|
@ -214,6 +232,7 @@ Future<PersonDependencies> _openPerson(
|
||||||
final pdfDeletions = PdfDeletions(pdfSources);
|
final pdfDeletions = PdfDeletions(pdfSources);
|
||||||
|
|
||||||
return PersonDependencies(
|
return PersonDependencies(
|
||||||
|
sync: sync,
|
||||||
studysets: studysets,
|
studysets: studysets,
|
||||||
cards: cards,
|
cards: cards,
|
||||||
notes: notes,
|
notes: notes,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||||
|
|
@ -19,6 +20,9 @@ import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||||
/// Opened when they sign in and disposed when the person changes, so nothing
|
/// Opened when they sign in and disposed when the person changes, so nothing
|
||||||
/// of one person's session reaches another's (see `PersonScope`).
|
/// of one person's session reaches another's (see `PersonScope`).
|
||||||
class PersonDependencies({
|
class PersonDependencies({
|
||||||
|
/// The sync of the person's data with the server, and where it stands.
|
||||||
|
required final SyncService sync,
|
||||||
|
|
||||||
/// The person's studysets.
|
/// The person's studysets.
|
||||||
required final StudysetRepository studysets,
|
required final StudysetRepository studysets,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
|
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_trouble_badge.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
|
@ -31,8 +32,8 @@ class const AppShell({
|
||||||
destinations: [
|
destinations: [
|
||||||
for (final destination in destinations)
|
for (final destination in destinations)
|
||||||
NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
icon: Icon(destination.icon),
|
icon: _icon(destination, destination.icon),
|
||||||
selectedIcon: Icon(destination.selectedIcon),
|
selectedIcon: _icon(destination, destination.selectedIcon),
|
||||||
label: Text(destination.label),
|
label: Text(destination.label),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -51,8 +52,8 @@ class const AppShell({
|
||||||
destinations: [
|
destinations: [
|
||||||
for (final destination in destinations)
|
for (final destination in destinations)
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(destination.icon),
|
icon: _icon(destination, destination.icon),
|
||||||
selectedIcon: Icon(destination.selectedIcon),
|
selectedIcon: _icon(destination, destination.selectedIcon),
|
||||||
label: destination.label,
|
label: destination.label,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -94,28 +95,42 @@ typedef _Destination = ({
|
||||||
String label,
|
String label,
|
||||||
IconData icon,
|
IconData icon,
|
||||||
IconData selectedIcon,
|
IconData selectedIcon,
|
||||||
|
|
||||||
|
/// Whether the tab carries the dot while sync is in trouble.
|
||||||
|
bool showsSyncTrouble,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Widget _icon(_Destination destination, IconData icon) =>
|
||||||
|
destination.showsSyncTrouble
|
||||||
|
? SyncTroubleBadge(child: Icon(icon))
|
||||||
|
: Icon(icon);
|
||||||
|
|
||||||
/// The tabs, in the order of the router's branches.
|
/// The tabs, in the order of the router's branches.
|
||||||
List<_Destination> _destinations(AppLocalizations l10n) => [
|
List<_Destination> _destinations(AppLocalizations l10n) => [
|
||||||
(
|
(
|
||||||
label: l10n.shellTabHome,
|
label: l10n.shellTabHome,
|
||||||
icon: LucideIcons.house,
|
icon: LucideIcons.house,
|
||||||
selectedIcon: LucideIcons.house,
|
selectedIcon: LucideIcons.house,
|
||||||
|
showsSyncTrouble: false,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
label: l10n.shellTabLibrary,
|
label: l10n.shellTabLibrary,
|
||||||
icon: LucideIcons.book,
|
icon: LucideIcons.book,
|
||||||
selectedIcon: LucideIcons.bookOpen,
|
selectedIcon: LucideIcons.bookOpen,
|
||||||
|
showsSyncTrouble: false,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
label: l10n.shellTabExplore,
|
label: l10n.shellTabExplore,
|
||||||
icon: LucideIcons.compass,
|
icon: LucideIcons.compass,
|
||||||
selectedIcon: LucideIcons.compass,
|
selectedIcon: LucideIcons.compass,
|
||||||
|
showsSyncTrouble: false,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
label: l10n.shellTabProfile,
|
label: l10n.shellTabProfile,
|
||||||
icon: LucideIcons.user,
|
icon: LucideIcons.user,
|
||||||
selectedIcon: LucideIcons.user,
|
selectedIcon: LucideIcons.user,
|
||||||
|
// Settings, where the Sync card explains the trouble, is reached from
|
||||||
|
// Profile.
|
||||||
|
showsSyncTrouble: true,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
|
||||||
|
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||||
|
|
||||||
|
/// This app and the server don't match: the server needs a newer app, or the
|
||||||
|
/// two disagree on the synced tables (one of them needs an update).
|
||||||
|
final class const AppServerMismatchException() implements Exception {
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
"AppServerMismatchException: this app and the server don't match";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the server still works with this app's build, asked once per
|
||||||
|
/// launch: published apps can't be made to update, so the server names the
|
||||||
|
/// oldest build it works with (`minimumAppBuild` in `instance.info()`).
|
||||||
|
class AppCompatibility({
|
||||||
|
/// This app's build (the number after `+` in its version), or null when it
|
||||||
|
/// has none, as in development: such a build is never refused.
|
||||||
|
required final int? appBuild,
|
||||||
|
|
||||||
|
/// Asks the server for the oldest build it works with; null for any.
|
||||||
|
required final Future<int?> Function() minimumAppBuild,
|
||||||
|
}) {
|
||||||
|
bool? _compatible;
|
||||||
|
|
||||||
|
/// Throws [AppServerMismatchException] if the server no longer works with
|
||||||
|
/// this build. A failure to ask passes on, and the next check asks again.
|
||||||
|
Future<void> check() async {
|
||||||
|
final build = appBuild;
|
||||||
|
if (build == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final compatible = _compatible ??= switch (await minimumAppBuild()) {
|
||||||
|
null => true,
|
||||||
|
final minimum => build >= minimum,
|
||||||
|
};
|
||||||
|
if (!compatible) {
|
||||||
|
throw const AppServerMismatchException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [sync], checked with [compatibility] first; a sync the engine refuses
|
||||||
|
/// because the synced tables differ from the server's also ends in
|
||||||
|
/// [AppServerMismatchException].
|
||||||
|
SyncOnce compatibleSync(SyncOnce sync, AppCompatibility compatibility) =>
|
||||||
|
({required onMerge}) async {
|
||||||
|
await compatibility.check();
|
||||||
|
try {
|
||||||
|
await sync(onMerge: onMerge);
|
||||||
|
} on OfflineSyncTablesHashMismatchException {
|
||||||
|
throw const AppServerMismatchException();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/sync_status.dart';
|
||||||
|
|
||||||
|
/// Where sync stands on this device, for the trouble dot, the Settings card
|
||||||
|
/// and the banner on Home; and syncing now when the person asks.
|
||||||
|
class SyncStatusBloc(final SyncService _sync)
|
||||||
|
extends Bloc<SyncStatusEvent, SyncStatus> {
|
||||||
|
this : super(_sync.status) {
|
||||||
|
on<SyncStatusEventStarted>(
|
||||||
|
(event, emit) => emit.forEach(
|
||||||
|
_sync.statusChanges,
|
||||||
|
onData: (status) => status,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
on<SyncStatusEventSyncRequested>((event, emit) => _sync.syncNow());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What happens to the sync status.
|
||||||
|
sealed class const SyncStatusEvent() with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The person's data opened: follow the sync.
|
||||||
|
final class const SyncStatusEventStarted() extends SyncStatusEvent;
|
||||||
|
|
||||||
|
/// The person asked to sync now.
|
||||||
|
final class const SyncStatusEventSyncRequested() extends SyncStatusEvent;
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/app_compatibility.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/sync_status.dart';
|
||||||
|
|
||||||
/// Syncs once and calls `onMerge` whenever changes from other devices arrive.
|
/// Syncs once and calls `onMerge` whenever changes from other devices arrive.
|
||||||
typedef SyncOnce = Future<void> Function({required VoidCallback onMerge});
|
typedef SyncOnce = Future<void> Function({required VoidCallback onMerge});
|
||||||
|
|
@ -11,7 +14,7 @@ typedef SyncOnce = Future<void> Function({required VoidCallback onMerge});
|
||||||
///
|
///
|
||||||
/// Continuous sync (the engine's live mode) comes once it has been hardened.
|
/// Continuous sync (the engine's live mode) comes once it has been hardened.
|
||||||
/// A failed sync, for example without a connection, is logged and retried on
|
/// A failed sync, for example without a connection, is logged and retried on
|
||||||
/// the next trigger.
|
/// the next trigger; [status] tells how the last one went.
|
||||||
class SyncService(
|
class SyncService(
|
||||||
final SyncOnce _syncOnce, {
|
final SyncOnce _syncOnce, {
|
||||||
|
|
||||||
|
|
@ -23,6 +26,8 @@ class SyncService(
|
||||||
}) {
|
}) {
|
||||||
final _merges = StreamController<void>.broadcast();
|
final _merges = StreamController<void>.broadcast();
|
||||||
final _synced = StreamController<void>.broadcast();
|
final _synced = StreamController<void>.broadcast();
|
||||||
|
final _statusChanges = StreamController<SyncStatus>.broadcast();
|
||||||
|
var _status = const SyncStatus();
|
||||||
Timer? _debounceTimer;
|
Timer? _debounceTimer;
|
||||||
Timer? _periodic;
|
Timer? _periodic;
|
||||||
AppLifecycleListener? _lifecycle;
|
AppLifecycleListener? _lifecycle;
|
||||||
|
|
@ -37,6 +42,12 @@ class SyncService(
|
||||||
/// the server now, and there is a connection.
|
/// the server now, and there is a connection.
|
||||||
Stream<void> get synced => _synced.stream;
|
Stream<void> get synced => _synced.stream;
|
||||||
|
|
||||||
|
/// Where sync stands on this device since the app started.
|
||||||
|
SyncStatus get status => _status;
|
||||||
|
|
||||||
|
/// Every change of [status].
|
||||||
|
Stream<SyncStatus> get statusChanges => _statusChanges.stream;
|
||||||
|
|
||||||
/// Syncs now, then on the triggers.
|
/// Syncs now, then on the triggers.
|
||||||
void start() {
|
void start() {
|
||||||
_periodic = Timer.periodic(interval, (_) => _run());
|
_periodic = Timer.periodic(interval, (_) => _run());
|
||||||
|
|
@ -50,6 +61,12 @@ class SyncService(
|
||||||
_debounceTimer = Timer(debounce, _run);
|
_debounceTimer = Timer(debounce, _run);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Syncs now, without waiting for local changes to settle: the person asked.
|
||||||
|
void syncNow() {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_run();
|
||||||
|
}
|
||||||
|
|
||||||
/// Stops syncing. A sync in progress finishes first, so the database can be
|
/// Stops syncing. A sync in progress finishes first, so the database can be
|
||||||
/// closed once this completes.
|
/// closed once this completes.
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
|
|
@ -60,6 +77,7 @@ class SyncService(
|
||||||
await _running;
|
await _running;
|
||||||
await _merges.close();
|
await _merges.close();
|
||||||
await _synced.close();
|
await _synced.close();
|
||||||
|
await _statusChanges.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// One sync at a time; a trigger during a sync runs one more afterwards.
|
// One sync at a time; a trigger during a sync runs one more afterwards.
|
||||||
|
|
@ -77,12 +95,34 @@ class SyncService(
|
||||||
Future<void> _syncWhileTriggered() async {
|
Future<void> _syncWhileTriggered() async {
|
||||||
do {
|
do {
|
||||||
_runAgain = false;
|
_runAgain = false;
|
||||||
|
_report(
|
||||||
|
SyncStatus(
|
||||||
|
syncing: true,
|
||||||
|
lastSyncedAt: _status.lastSyncedAt,
|
||||||
|
failure: _status.failure,
|
||||||
|
),
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await _syncOnce(onMerge: () => _merges.add(null));
|
await _syncOnce(onMerge: () => _merges.add(null));
|
||||||
|
_report(SyncStatus(lastSyncedAt: clock.now()));
|
||||||
_synced.add(null);
|
_synced.add(null);
|
||||||
} on Object catch (error, st) {
|
} on Object catch (error, st) {
|
||||||
log('Sync failed', name: 'SyncService', error: error, stackTrace: st);
|
log('Sync failed', name: 'SyncService', error: error, stackTrace: st);
|
||||||
|
_report(
|
||||||
|
SyncStatus(
|
||||||
|
lastSyncedAt: _status.lastSyncedAt,
|
||||||
|
failure: (
|
||||||
|
at: clock.now(),
|
||||||
|
appMismatch: error is AppServerMismatchException,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} while (_runAgain && !_stopping);
|
} while (_runAgain && !_stopping);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _report(SyncStatus status) {
|
||||||
|
_status = status;
|
||||||
|
_statusChanges.add(status);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
halkyon_learn_flutter/lib/src/core/sync/sync_status.dart
Normal file
30
halkyon_learn_flutter/lib/src/core/sync/sync_status.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// A failed sync: when, and whether because this app and the server don't
|
||||||
|
/// match.
|
||||||
|
typedef SyncFailure = ({DateTime at, bool appMismatch});
|
||||||
|
|
||||||
|
/// Where sync stands on this device since the app started. Only the most
|
||||||
|
/// recent outcome counts: one sync that goes through clears a failure.
|
||||||
|
final class const SyncStatus({
|
||||||
|
/// Whether a sync is running now.
|
||||||
|
final bool syncing = false,
|
||||||
|
|
||||||
|
/// When a sync last went through since the app started, or null.
|
||||||
|
final DateTime? lastSyncedAt,
|
||||||
|
|
||||||
|
/// How the most recent sync failed, or null if it went through (or none
|
||||||
|
/// has run yet).
|
||||||
|
final SyncFailure? failure,
|
||||||
|
}) with Equatable {
|
||||||
|
/// Whether sync needs the person's attention: its last outcome was a
|
||||||
|
/// failure. A sync running meanwhile doesn't hide it.
|
||||||
|
bool get hasTrouble => failure != null;
|
||||||
|
|
||||||
|
/// Whether the last sync failed because this app and the server don't
|
||||||
|
/// match: one of them needs an update.
|
||||||
|
bool get appMismatch => failure?.appMismatch ?? false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [syncing, lastSyncedAt, failure];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// A banner while this app and the server don't match, so the person updates
|
||||||
|
/// the app: unlike a passing failure, it doesn't go by itself. More leads to
|
||||||
|
/// the Sync card in Settings. Nothing otherwise.
|
||||||
|
class const AppMismatchBanner({super.key}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final mismatch = context.select<SyncStatusBloc, bool>(
|
||||||
|
(bloc) => bloc.state.appMismatch,
|
||||||
|
);
|
||||||
|
if (!mismatch) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
return MaterialBanner(
|
||||||
|
backgroundColor: colors.errorContainer,
|
||||||
|
leading: Icon(LucideIcons.cloudAlert, color: colors.onErrorContainer),
|
||||||
|
content: Text(
|
||||||
|
l10n.syncMismatchBanner(DependenciesScope.of(context).serverName),
|
||||||
|
style: TextStyle(color: colors.onErrorContainer),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.push(RoutePaths.settings),
|
||||||
|
child: Text(l10n.syncMismatchMore),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/sync_status.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// The Sync card in Settings: where sync stands, in one line and a detail,
|
||||||
|
/// and Sync now. The one place that answers whether the person's changes
|
||||||
|
/// reach the server.
|
||||||
|
class const SyncStatusCard({super.key}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final server = DependenciesScope.of(context).serverName;
|
||||||
|
return BlocBuilder<SyncStatusBloc, SyncStatus>(
|
||||||
|
builder: (context, status) {
|
||||||
|
String time(DateTime at) =>
|
||||||
|
DateFormat.jm(l10n.localeName).format(at.toLocal());
|
||||||
|
final completed = switch (status.lastSyncedAt) {
|
||||||
|
final at? => l10n.syncLastCompleted(time(at)),
|
||||||
|
null => l10n.syncNoRunYet,
|
||||||
|
};
|
||||||
|
final (state, details) = switch (status) {
|
||||||
|
SyncStatus(appMismatch: true) => (
|
||||||
|
l10n.syncStateMismatch(server),
|
||||||
|
[l10n.syncMismatchDetail, completed],
|
||||||
|
),
|
||||||
|
SyncStatus(syncing: true, :final lastSyncedAt) => (
|
||||||
|
l10n.syncStateSyncing,
|
||||||
|
[if (lastSyncedAt != null) completed],
|
||||||
|
),
|
||||||
|
SyncStatus(failure: (:final at, appMismatch: _)) => (
|
||||||
|
l10n.syncStateFailing,
|
||||||
|
[l10n.syncLastFailed(time(at)), completed],
|
||||||
|
),
|
||||||
|
SyncStatus(lastSyncedAt: _?) => (l10n.syncStateSynced, [completed]),
|
||||||
|
_ => (l10n.syncStateNotYet, <String>[]),
|
||||||
|
};
|
||||||
|
return SectionCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.syncTitle,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
// The state line shows the sync running.
|
||||||
|
onPressed: status.syncing
|
||||||
|
? null
|
||||||
|
: () => context.read<SyncStatusBloc>().add(
|
||||||
|
const SyncStatusEventSyncRequested(),
|
||||||
|
),
|
||||||
|
icon: const Icon(LucideIcons.refreshCw),
|
||||||
|
label: Text(l10n.syncNow),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
state,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: status.hasTrouble && !status.syncing
|
||||||
|
? theme.colorScheme.error
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final line in details)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
line,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// [child] with a small error-coloured dot while the last sync failed, and
|
||||||
|
/// as it is otherwise: syncing, or everything synced, shows nothing.
|
||||||
|
///
|
||||||
|
/// On the Settings cog, where the Sync card explains it, and on the Profile
|
||||||
|
/// tab, which leads there.
|
||||||
|
class const SyncTroubleBadge({required final Widget child, super.key})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final trouble = context.select<SyncStatusBloc, bool>(
|
||||||
|
(bloc) => bloc.state.hasTrouble,
|
||||||
|
);
|
||||||
|
if (!trouble) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
return Semantics(
|
||||||
|
label: AppLocalizations.of(context).syncTroubleLabel,
|
||||||
|
child: Badge(
|
||||||
|
smallSize: 8,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart';
|
import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_trouble_badge.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/github_sign_in_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/github_sign_in_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
|
||||||
|
|
@ -29,7 +30,9 @@ class const ProfileScreen({super.key}) extends StatelessWidget {
|
||||||
title: Text(l10n.profileTitle),
|
title: Text(l10n.profileTitle),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(LucideIcons.settings),
|
icon: const SyncTroubleBadge(
|
||||||
|
child: Icon(LucideIcons.settings),
|
||||||
|
),
|
||||||
tooltip: l10n.profileSettingsTooltip,
|
tooltip: l10n.profileSettingsTooltip,
|
||||||
onPressed: () => context.push(RoutePaths.settings),
|
onPressed: () => context.push(RoutePaths.settings),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/widget/app_mismatch_banner.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/home/bloc/home_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/home/bloc/home_bloc.dart';
|
||||||
|
|
@ -103,9 +104,16 @@ class _HomeState() extends State<_Home> {
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: loaded == null || today == null
|
body: Column(
|
||||||
? const Center(child: AppLoadingIndicator())
|
children: [
|
||||||
: _dashboard(loaded, today),
|
const AppMismatchBanner(),
|
||||||
|
Expanded(
|
||||||
|
child: loaded == null || today == null
|
||||||
|
? const Center(child: AppLoadingIndicator())
|
||||||
|
: _dashboard(loaded, today),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_status_card.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/about/widget/about.dart';
|
import 'package:halkyon_learn_flutter/src/feature/about/widget/about.dart';
|
||||||
|
|
@ -9,8 +10,8 @@ import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// Settings, over the tabs: the account (signing out, deleting it) and what
|
/// Settings, over the tabs: where sync stands, the account (signing out,
|
||||||
/// Halkyon Learn is.
|
/// deleting it) and what Halkyon Learn is.
|
||||||
class const SettingsScreen({super.key}) extends StatelessWidget {
|
class const SettingsScreen({super.key}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -28,6 +29,8 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
children: [
|
children: [
|
||||||
|
const SyncStatusCard(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
SectionCard(
|
SectionCard(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue