merged develop

This commit is contained in:
2026-09-27 14:58:07 +02:00
188 changed files with 7474 additions and 2558 deletions
@@ -1,11 +1,14 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:flutter_app_badge/flutter_app_badge.dart';
import '../../../../../access/access_requirement.dart';
import '../../../../../api/marianumcloud/talk/actions/talk_actions.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../../utils/session_single_flight.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../repository/chat_list_repository.dart';
@@ -31,6 +34,13 @@ class ChatListBloc
return super.close();
}
// The timer outlives the app shell; the next shell re-arms it after login.
@override
Future<void> reset() {
setAutoRefreshInterval(null);
return super.reset();
}
/// Silent refresh — explicit pull-to-refresh and tab-activation are non-silent.
void setAutoRefreshInterval(Duration? interval) {
if (interval == _autoRefreshInterval) return;
@@ -72,14 +82,34 @@ class ChatListBloc
renew: renew,
onError: (e) => capturedError = e,
);
_lastRoomsJson = null;
add(DataGathered((s) => s.copyWith(rooms: rooms)));
_updateAppBadge(rooms);
if (capturedError != null) throw capturedError!;
}
final SessionSingleFlight _refreshFlight = SessionSingleFlight();
/// Concurrent callers (tab switch, poll, push, resume) join the running
/// refresh instead of each fetching and re-emitting the full room list.
Future<void> refresh({bool renew = true, bool silent = false}) async {
if (!requirementsMet) return;
if (!requirementsMet || !ownsSession) return;
await _refreshFlight.run(() => _refresh(renew: renew, silent: silent));
}
/// Skips the refresh when the list was fetched within [maxAge].
Future<void> refreshIfOlderThan(Duration maxAge) {
final lastFetch = state.lastFetch;
if (lastFetch != null &&
DateTime.now().millisecondsSinceEpoch - lastFetch <
maxAge.inMilliseconds) {
return Future.value();
}
return refresh();
}
Future<void> _refresh({required bool renew, required bool silent}) async {
if (!silent) add(RefetchStarted<ChatListState>());
Object? capturedError;
try {
@@ -87,6 +117,11 @@ class ChatListBloc
renew: renew,
onError: (e) => capturedError = e,
);
if (silent) {
if (_isUnchanged(rooms)) return;
} else {
_lastRoomsJson = null;
}
add(DataGathered((s) => s.copyWith(rooms: rooms)));
_updateAppBadge(rooms);
} catch (e) {
@@ -95,6 +130,23 @@ class ChatListBloc
if (capturedError != null) addLoadingError(capturedError!);
}
// Encoded room data of the last applied poll result; response headers are
// left out because they differ on every request.
String? _lastRoomsJson;
/// A background poll that returns the same rooms would otherwise rebuild the
/// list and re-persist the whole state every 15 s.
bool _isUnchanged(GetRoomResponse rooms) {
final encoded = jsonEncode(rooms.data.toList());
final unchanged =
encoded == _lastRoomsJson &&
innerState?.rooms != null &&
state.error == null &&
!state.isLoading;
_lastRoomsJson = encoded;
return unchanged;
}
/// Creates (or resolves) a 1:1 chat and returns its room token, or null in
/// demo mode. Refreshes the list so the room shows up.
Future<String?> createDirectChat(String invite) async {
@@ -126,6 +178,18 @@ class ChatListBloc
});
}
Future<void> setNotificationLevel(
String token,
GetRoomResponseObjectParticipantNotificationLevel level,
) async {
await SetNotificationLevel(token, level).run();
_mutateRoom(token, (r) {
if (r.notificationLevel == level) return false;
r.notificationLevel = level;
return true;
});
}
/// Clears unread too — long-poll only feeds this in for an actively-open chat.
void applyIncomingMessage(String token, GetChatResponseObject message) {
_mutateRoom(token, (r) {
@@ -158,6 +222,7 @@ class ChatListBloc
}).toSet();
if (!changed) return;
final newRooms = GetRoomResponse(updated)..headers = rooms.headers;
_lastRoomsJson = null;
add(Emit((s) => s.copyWith(rooms: newRooms)));
_updateAppBadge(newRooms);
}