Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
2026-08-01 18:19:56 +02:00
4 changed files with 58 additions and 29 deletions
@@ -12,8 +12,9 @@ import 'telemetry_device_id.dart';
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
/// upserts the stable install id, platform, app version and device info. Sent
/// once on app start and again once push registration completes that session
/// (so a fresh registration isn't under-reported until the next launch).
/// on app start, again once push registration completes that session (so a
/// fresh registration isn't under-reported until the next launch), and on
/// resume after the app spent more than 15 minutes in the background.
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
/// mhsl.eu `server/userIndex/update` call.
class TelemetryHeartbeat extends MarianumConnectQuery {
@@ -21,7 +22,8 @@ class TelemetryHeartbeat extends MarianumConnectQuery {
/// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed
/// send never disrupts app start. Used from the app shell's initState and
/// re-emitted once push registration completes (see `_MainState._syncPush`).
/// lifecycle handler, and re-emitted once push registration completes (see
/// `_MainState._syncPush`).
static void report({required bool notificationsEnabled}) {
unawaited(
TelemetryHeartbeat()
+22 -4
View File
@@ -46,9 +46,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
int _knownTotalTabs = 1;
int _lastTabIndex = 0;
bool _userOnLastTab = false;
DateTime? _lastTelemetryAt;
static const Duration _chatListActiveInterval = Duration(seconds: 15);
static const Duration _chatListIdleInterval = Duration(seconds: 60);
static const Duration _telemetryInterval = Duration(minutes: 15);
void _onTabControllerChanged() {
final newIndex = Main.bottomNavigator.index;
@@ -73,10 +75,29 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (talkIsActive) bloc.refresh();
}
// Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick
// reliably while the app is suspended, so the window would still be open on
// the resume it is supposed to let through.
void _reportTelemetry() {
if (!mounted) return;
final now = DateTime.now();
final last = _lastTelemetryAt;
if (last != null && now.difference(last) < _telemetryInterval) return;
_lastTelemetryAt = now;
TelemetryHeartbeat.report(
notificationsEnabled: context
.read<SettingsCubit>()
.val()
.notificationSettings
.enabled,
);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
log('AppLifecycle: $state');
if (state == AppLifecycleState.resumed) {
_reportTelemetry();
Debouncer.throttle('appLifecycleState', const Duration(seconds: 10), () {
if (!mounted) return;
log('Refreshing due to LifecycleChange');
@@ -178,10 +199,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (mounted) setState(() {});
});
TelemetryHeartbeat.report(
notificationsEnabled:
context.read<SettingsCubit>().val().notificationSettings.enabled,
);
_reportTelemetry();
// A refreshed FCM token invalidates the existing push subscription — the
// NC device identifier stays stable, so we simply re-register (NC first,
@@ -165,23 +165,28 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
Widget _buildBody(BuildContext context) => FutureBuilder<void>(
future: _init,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: AppProgressIndicator.large());
}
if (snapshot.hasError) {
return PlaceholderView(
icon: Icons.error_outline,
text: errorToUserMessage(snapshot.error),
button: ElevatedButton.icon(
onPressed: () => setState(() => _init = _load()),
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
),
);
}
return _buildForm(context);
},
);
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: AppProgressIndicator.large());
}
if (snapshot.hasError) {
return PlaceholderView(
icon: Icons.error_outline,
text: errorToUserMessage(snapshot.error),
button: ElevatedButton.icon(
onPressed: () {
final reload = _load();
setState(() {
_init = reload;
});
},
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
),
);
}
return _buildForm(context);
},
);
Widget _buildForm(BuildContext context) {
final theme = Theme.of(context);
+9 -5
View File
@@ -230,11 +230,15 @@ class _UserAvatarState extends State<UserAvatar> {
final pending = _pendingAvatars.putIfAbsent(url, () {
final future = _fetch(url);
future.whenComplete(() {
if (identical(_pendingAvatars[url], future)) {
_pendingAvatars.remove(url);
}
});
// Cleanup hangs off an error-neutralised copy: whenComplete on `future`
// itself returns a second future that forwards the error unawaited.
unawaited(
future.then<void>((_) {}, onError: (_) {}).whenComplete(() {
if (identical(_pendingAvatars[url], future)) {
_pendingAvatars.remove(url);
}
}),
);
return future;
});