Skip to content

Commit 26b5a71

Browse files
Add delta-reconnect optimization, pupil label printing, and test suite
Delta-reconnect: server tracks per-HubObjectType change timestamps in memory (HubUpdatesTracker), exposes via getLastChangeTimes RPC endpoint, and instruments all 50 broadcast sites with touch() calls. Client compares server change times against disconnect timestamp and emits HubSelectiveReconnect instead of full HubReconnected. All 10 managers handle selective reconnect with fallback to full refetch on failure. Pupil labels: add PupilLabelPdfService generating AVERY 5759 format labels with QR code, school grade, learning group, and school name. Integrated into School Lists page. Tests: 136 tests covering HubUpdatesTracker, delta comparison logic, DateTimeExtensions (client + server), SchooldayEventFilterPredicates, PupilIdentityHelper CSV parsing, PupilIdentityExtension serialization/ equality, PupilProxyHelper predicates, and AES-CBC encrypt/decrypt round-trips.
1 parent 8275d26 commit 26b5a71

78 files changed

Lines changed: 4229 additions & 2099 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/model_relations/school_data_hub_server.svg

Lines changed: 1 addition & 1 deletion
Loading
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
2+
/* To generate run: "serverpod generate" */
3+
4+
// ignore_for_file: implementation_imports
5+
// ignore_for_file: library_private_types_in_public_api
6+
// ignore_for_file: non_constant_identifier_names
7+
// ignore_for_file: public_member_api_docs
8+
// ignore_for_file: type_literal_in_constant_pattern
9+
// ignore_for_file: use_super_parameters
10+
11+
// ignore_for_file: no_leading_underscores_for_library_prefixes
12+
import 'package:serverpod_client/serverpod_client.dart' as _i1;
13+
import '../../../_features/hub/models/hub_object_type.dart' as _i2;
14+
15+
abstract class HubTypeLastUpdate implements _i1.SerializableModel {
16+
HubTypeLastUpdate._({
17+
required this.objectType,
18+
required this.changedAt,
19+
});
20+
21+
factory HubTypeLastUpdate({
22+
required _i2.HubObjectType objectType,
23+
required DateTime changedAt,
24+
}) = _HubTypeLastUpdateImpl;
25+
26+
factory HubTypeLastUpdate.fromJson(Map<String, dynamic> jsonSerialization) {
27+
return HubTypeLastUpdate(
28+
objectType:
29+
_i2.HubObjectType.fromJson((jsonSerialization['objectType'] as int)),
30+
changedAt:
31+
_i1.DateTimeJsonExtension.fromJson(jsonSerialization['changedAt']),
32+
);
33+
}
34+
35+
_i2.HubObjectType objectType;
36+
37+
DateTime changedAt;
38+
39+
/// Returns a shallow copy of this [HubTypeLastUpdate]
40+
/// with some or all fields replaced by the given arguments.
41+
@_i1.useResult
42+
HubTypeLastUpdate copyWith({
43+
_i2.HubObjectType? objectType,
44+
DateTime? changedAt,
45+
});
46+
@override
47+
Map<String, dynamic> toJson() {
48+
return {
49+
'objectType': objectType.toJson(),
50+
'changedAt': changedAt.toJson(),
51+
};
52+
}
53+
54+
@override
55+
String toString() {
56+
return _i1.SerializationManager.encode(this);
57+
}
58+
}
59+
60+
class _HubTypeLastUpdateImpl extends HubTypeLastUpdate {
61+
_HubTypeLastUpdateImpl({
62+
required _i2.HubObjectType objectType,
63+
required DateTime changedAt,
64+
}) : super._(
65+
objectType: objectType,
66+
changedAt: changedAt,
67+
);
68+
69+
/// Returns a shallow copy of this [HubTypeLastUpdate]
70+
/// with some or all fields replaced by the given arguments.
71+
@_i1.useResult
72+
@override
73+
HubTypeLastUpdate copyWith({
74+
_i2.HubObjectType? objectType,
75+
DateTime? changedAt,
76+
}) {
77+
return HubTypeLastUpdate(
78+
objectType: objectType ?? this.objectType,
79+
changedAt: changedAt ?? this.changedAt,
80+
);
81+
}
82+
}

school_data_hub_client/lib/src/protocol/client.dart

Lines changed: 286 additions & 277 deletions
Large diffs are not rendered by default.

school_data_hub_client/lib/src/protocol/protocol.dart

Lines changed: 692 additions & 671 deletions
Large diffs are not rendered by default.

school_data_hub_flutter/assets/school_data_hub_server.svg

Lines changed: 1 addition & 1 deletion
Loading

school_data_hub_flutter/lib/common/widgets/generic_components/generic_app_bar.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import 'package:flutter/material.dart';
22
import 'package:flutter_it/flutter_it.dart';
3-
import 'package:school_data_hub_flutter/common/services/hub_stream_service.dart';
3+
import 'package:school_data_hub_flutter/core/client/hub_stream_service.dart';
44
import 'package:school_data_hub_flutter/common/services/notification_service.dart';
55
import 'package:school_data_hub_flutter/common/theme/app_colors.dart';
66
import 'package:school_data_hub_flutter/common/theme/styles.dart';

school_data_hub_flutter/lib/common/data/file_upload_service.dart renamed to school_data_hub_flutter/lib/core/client/file_upload_service.dart

File renamed without changes.

school_data_hub_flutter/lib/common/services/hub_stream_service.dart renamed to school_data_hub_flutter/lib/core/client/hub_stream_service.dart

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,28 @@ sealed class HubLocalEvent {}
2828
/// Emitted after a successful reconnect; subscribers should refetch their data.
2929
final class HubReconnected extends HubLocalEvent {}
3030

31+
/// Emitted when only specific object types changed during disconnect.
32+
final class HubSelectiveReconnect extends HubLocalEvent {
33+
final Set<HubObjectType> changedTypes;
34+
HubSelectiveReconnect(this.changedTypes);
35+
}
36+
37+
/// Given a list of per-type last-change timestamps from the server and
38+
/// the moment this client disconnected, returns the set of [HubObjectType]s
39+
/// that were modified while the client was offline.
40+
///
41+
/// Extracted as a top-level function so it can be unit-tested without
42+
/// standing up [HubStreamService].
43+
Set<HubObjectType> computeChangedTypes(
44+
List<HubTypeLastUpdate> changeTimes,
45+
DateTime disconnectedAt,
46+
) {
47+
return changeTimes
48+
.where((ct) => ct.changedAt.isAfter(disconnectedAt))
49+
.map((ct) => ct.objectType)
50+
.toSet();
51+
}
52+
3153
/// Backoff cap in milliseconds.
3254
const _maxReconnectDelayMs = 30000;
3355
const _initialReconnectDelayMs = 1000;
@@ -48,6 +70,7 @@ class HubStreamService with WidgetsBindingObserver {
4870
final _random = Random();
4971
VoidCallback? _connectivityListener;
5072
String? _currentDeviceId;
73+
DateTime? _disconnectedAt;
5174

5275
final _events = StreamController<Object>.broadcast();
5376

@@ -133,12 +156,26 @@ class HubStreamService with WidgetsBindingObserver {
133156
}
134157

135158
void _cleanupSubscription() {
159+
if (_state.value == HubConnectionState.connected) {
160+
_disconnectedAt = DateTime.now().toUtc();
161+
}
136162
_hasReceivedFirstEvent = false;
137163
final sub = _subscription;
138164
_subscription = null;
139165
sub?.cancel();
140166
}
141167

168+
Future<Set<HubObjectType>?> _getChangedTypesSinceDisconnect() async {
169+
if (_disconnectedAt == null) return null;
170+
try {
171+
final changeTimes = await di<Client>().hub.getLastChangeTimes();
172+
return computeChangedTypes(changeTimes, _disconnectedAt!);
173+
} catch (e) {
174+
_log.warning('[HUB] Could not fetch change times: $e');
175+
return null;
176+
}
177+
}
178+
142179
void _scheduleReconnect({
143180
required bool isReconnect,
144181
required int delayMs,
@@ -181,15 +218,25 @@ class HubStreamService with WidgetsBindingObserver {
181218
if (isReconnect) {
182219
_reconnectTimer = Timer(
183220
const Duration(milliseconds: _dnsGraceDelayMs),
184-
() {
221+
() async {
185222
_reconnectTimer = null;
186223
if (!_appInForeground || _disposed) {
187224
_connecting = false;
188225
return;
189226
}
190-
if (!_disposed) {
227+
228+
final changedTypes = await _getChangedTypesSinceDisconnect();
229+
if (changedTypes == null) {
191230
_events.add(HubReconnected());
231+
} else if (changedTypes.isNotEmpty) {
232+
_log.info(
233+
'[HUB] Selective reconnect: ${changedTypes.length} types changed',
234+
);
235+
_events.add(HubSelectiveReconnect(changedTypes));
236+
} else {
237+
_log.info('[HUB] No events missed — skipping refetch');
192238
}
239+
193240
_doSubscribe();
194241
},
195242
);

school_data_hub_flutter/lib/core/init/init_on_user_auth.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import 'package:logging/logging.dart';
33
import 'package:school_data_hub_client/school_data_hub_client.dart';
44
import 'package:school_data_hub_flutter/app_utils/secure_storage.dart';
55
import 'package:school_data_hub_flutter/common/domain/filters/filters_state_manager.dart';
6-
import 'package:school_data_hub_flutter/common/services/hub_stream_service.dart';
6+
import 'package:school_data_hub_flutter/core/client/hub_stream_service.dart';
77
import 'package:school_data_hub_flutter/core/env/env_manager.dart';
88
import 'package:school_data_hub_flutter/core/init/init_manager.dart';
99
import 'package:school_data_hub_flutter/core/session/hub_session_manager.dart';

school_data_hub_flutter/lib/features/_attendance/domain/attendance_manager.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart';
55
import 'package:flutter_it/flutter_it.dart';
66
import 'package:logging/logging.dart';
77
import 'package:school_data_hub_client/school_data_hub_client.dart';
8-
import 'package:school_data_hub_flutter/common/services/hub_stream_service.dart';
8+
import 'package:school_data_hub_flutter/core/client/hub_stream_service.dart';
99
import 'package:school_data_hub_flutter/common/services/notification_service.dart';
1010
import 'package:school_data_hub_flutter/core/models/datetime_extensions.dart';
1111
import 'package:school_data_hub_flutter/core/session/hub_session_manager.dart';
@@ -62,6 +62,10 @@ class AttendanceManager with ChangeNotifier {
6262
deleteFromStream(event.id);
6363
} else if (event is HubReconnected) {
6464
fetchAllPupilMissedSchooldayes();
65+
} else if (event is HubSelectiveReconnect) {
66+
if (event.changedTypes.contains(HubObjectType.missedSchoolday)) {
67+
fetchAllPupilMissedSchooldayes();
68+
}
6569
}
6670
}
6771

0 commit comments

Comments
 (0)