implemented backward pagination for chat history with on-scroll prefetching
This commit is contained in:
@@ -16,7 +16,10 @@ class GetChatCache extends SimpleCache<GetChatResponse> {
|
||||
GetChatParams(
|
||||
lookIntoFuture: GetChatParamsSwitch.off,
|
||||
setReadMarker: GetChatParamsSwitch.on,
|
||||
limit: 200,
|
||||
// Small initial page; also the per-chat offline snapshot written to
|
||||
// localstore. Older messages are paged in on scroll-up via
|
||||
// GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize.
|
||||
limit: 50,
|
||||
),
|
||||
).run(),
|
||||
fromJson: GetChatResponse.fromJson,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../errors/server_exception.dart';
|
||||
import '../../../http_errors.dart';
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import 'get_chat_params.dart';
|
||||
import 'get_chat_response.dart';
|
||||
|
||||
/// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`)
|
||||
/// that fetches the page of messages *older* than a given id. Bypasses [TalkApi]
|
||||
/// because that layer treats non-2xx as errors, and the server answers HTTP 304
|
||||
/// when there are no older messages left — a normal "start of chat" outcome here.
|
||||
/// `setReadMarker=off` so paging into history never moves the read cursor.
|
||||
class GetChatHistory {
|
||||
final String chatToken;
|
||||
final int lastKnownMessageId;
|
||||
final int limit;
|
||||
|
||||
GetChatHistory({
|
||||
required this.chatToken,
|
||||
required this.lastKnownMessageId,
|
||||
required this.limit,
|
||||
});
|
||||
|
||||
/// Returns the older page, or `null` on HTTP 304 (no older messages).
|
||||
Future<GetChatResponse?> run() async {
|
||||
final params = GetChatParams(
|
||||
lookIntoFuture: GetChatParamsSwitch.off,
|
||||
lastKnownMessageId: lastKnownMessageId,
|
||||
includeLastKnown: GetChatParamsSwitch.off,
|
||||
setReadMarker: GetChatParamsSwitch.off,
|
||||
limit: limit,
|
||||
);
|
||||
final uri = NextcloudOcs.uri(
|
||||
'apps/spreed/api/v1/chat/$chatToken',
|
||||
queryParameters: params.toJson(),
|
||||
);
|
||||
final headers = NextcloudOcs.headers();
|
||||
|
||||
final response = (await sendGuarded(
|
||||
'GetChatHistory $uri',
|
||||
() => http.get(uri, headers: headers),
|
||||
))!;
|
||||
|
||||
final status = response.statusCode;
|
||||
if (status == 304) return null;
|
||||
if (status >= 200 && status < 300) {
|
||||
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
|
||||
..headers = response.headers;
|
||||
}
|
||||
throw ServerException(
|
||||
statusCode: status,
|
||||
technicalDetails: 'GetChatHistory $uri: HTTP $status',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../../../../api/marianumcloud/talk/chat/get_chat_history.dart';
|
||||
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart';
|
||||
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
@@ -56,7 +57,22 @@ class ChatBloc
|
||||
ChatState fromStorage(Map<String, dynamic> json) => ChatState.fromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic>? toStorage(ChatState state) => state.toJson();
|
||||
Map<String, dynamic>? toStorage(ChatState state) {
|
||||
final response = state.chatResponse;
|
||||
if (response == null ||
|
||||
response.data.length <= _kMaxPersistedMessages) {
|
||||
return state.toJson();
|
||||
}
|
||||
// Keep only the newest N; trimming drops older messages, so there is
|
||||
// definitely more history to page back in after a restart.
|
||||
final newest = response
|
||||
.sortByTimestamp()
|
||||
.reversed
|
||||
.take(_kMaxPersistedMessages)
|
||||
.toSet();
|
||||
final trimmed = GetChatResponse(newest)..headers = response.headers;
|
||||
return state.copyWith(chatResponse: trimmed, hasMoreOld: true).toJson();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> gatherData() async {
|
||||
@@ -75,7 +91,16 @@ class ChatBloc
|
||||
return;
|
||||
}
|
||||
_stopLongPoll();
|
||||
add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null)));
|
||||
add(
|
||||
Emit(
|
||||
(s) => s.copyWith(
|
||||
currentToken: token,
|
||||
chatResponse: null,
|
||||
hasMoreOld: true,
|
||||
isLoadingOlder: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
add(RefetchStarted<ChatState>());
|
||||
_scheduleLoad(token);
|
||||
}
|
||||
@@ -100,6 +125,40 @@ class ChatBloc
|
||||
_stopLongPoll();
|
||||
}
|
||||
|
||||
/// Pages in the next block of messages older than the oldest currently held.
|
||||
/// No-ops when nothing more can be loaded or a load is already in flight.
|
||||
Future<void> loadOlder() async {
|
||||
final state = innerState;
|
||||
if (state == null) return;
|
||||
final token = state.currentToken;
|
||||
if (token.isEmpty) return;
|
||||
if (state.isLoadingOlder || !state.hasMoreOld) return;
|
||||
final response = state.chatResponse;
|
||||
if (response == null) return;
|
||||
final oldestId = _minMessageId(response);
|
||||
if (oldestId <= 0) return;
|
||||
|
||||
add(Emit((s) => s.copyWith(isLoadingOlder: true)));
|
||||
try {
|
||||
final older = await GetChatHistory(
|
||||
chatToken: token,
|
||||
lastKnownMessageId: oldestId,
|
||||
limit: _kOlderPageSize,
|
||||
).run();
|
||||
if (isClosed) return;
|
||||
if ((innerState?.currentToken ?? '') != token) return;
|
||||
if (older == null || older.data.isEmpty) {
|
||||
add(Emit((s) => s.copyWith(hasMoreOld: false, isLoadingOlder: false)));
|
||||
return;
|
||||
}
|
||||
_applyOlderResponse(older);
|
||||
} on Object catch (e) {
|
||||
log('Load older messages for $token failed: $e');
|
||||
if (isClosed) return;
|
||||
add(Emit((s) => s.copyWith(isLoadingOlder: false)));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendServerReadMarker(String token, int messageId) async {
|
||||
try {
|
||||
await SetReadMarker(
|
||||
@@ -245,7 +304,15 @@ class ChatBloc
|
||||
void _applyChatResponse(GetChatResponse incoming) {
|
||||
final current = innerState?.chatResponse;
|
||||
if (current == null) {
|
||||
add(DataGathered((s) => s.copyWith(chatResponse: incoming)));
|
||||
// Initial load: a short first page means there is nothing older to page in.
|
||||
add(
|
||||
DataGathered(
|
||||
(s) => s.copyWith(
|
||||
chatResponse: incoming,
|
||||
hasMoreOld: incoming.data.length >= _kInitialPageSize,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final byId = <int, GetChatResponseObject>{};
|
||||
@@ -260,6 +327,32 @@ class ChatBloc
|
||||
add(DataGathered((s) => s.copyWith(chatResponse: merged)));
|
||||
}
|
||||
|
||||
/// Merges an older history page. Unlike [_applyChatResponse] it keeps the
|
||||
/// current headers — the older page's `x-chat-last-common-read` would regress
|
||||
/// the read-status shown for already-visible messages.
|
||||
void _applyOlderResponse(GetChatResponse older) {
|
||||
final current = innerState?.chatResponse;
|
||||
if (current == null) return;
|
||||
final byId = <int, GetChatResponseObject>{};
|
||||
for (final m in current.data) {
|
||||
byId[m.id] = m;
|
||||
}
|
||||
for (final m in older.data) {
|
||||
byId.putIfAbsent(m.id, () => m);
|
||||
}
|
||||
final merged = GetChatResponse(byId.values.toSet())
|
||||
..headers = current.headers;
|
||||
add(
|
||||
DataGathered(
|
||||
(s) => s.copyWith(
|
||||
chatResponse: merged,
|
||||
hasMoreOld: older.data.length >= _kOlderPageSize,
|
||||
isLoadingOlder: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int _maxMessageId(GetChatResponse? response) {
|
||||
if (response == null) return 0;
|
||||
var max = 0;
|
||||
@@ -269,6 +362,16 @@ class ChatBloc
|
||||
return max;
|
||||
}
|
||||
|
||||
int _minMessageId(GetChatResponse? response) {
|
||||
if (response == null) return 0;
|
||||
var min = 0;
|
||||
for (final m in response.data) {
|
||||
if (m.id <= 0) continue; // skip dummies
|
||||
if (min == 0 || m.id < min) min = m.id;
|
||||
}
|
||||
return min;
|
||||
}
|
||||
|
||||
/// Mirrors the server's own `lastMessage` selection (comments + voice only).
|
||||
GetChatResponseObject? _pickDisplayMessage(GetChatResponse response) {
|
||||
GetChatResponseObject? best;
|
||||
@@ -288,3 +391,16 @@ class ChatBloc
|
||||
}
|
||||
|
||||
const _kLongPollLastGivenHeader = 'x-chat-last-given';
|
||||
|
||||
/// Small first page shown on open (keeps the initial request cheap). Must match
|
||||
/// the `limit` in GetChatCache (get_chat_cache.dart).
|
||||
const _kInitialPageSize = 50;
|
||||
|
||||
/// Upper bound on how many messages are persisted via HydratedBloc. The full
|
||||
/// scrolled history stays in memory at runtime; only the newest this-many
|
||||
/// survive a restart (older ones are re-fetchable via scroll-up).
|
||||
const _kMaxPersistedMessages = 500;
|
||||
|
||||
/// Larger block fetched per scroll-up so paging back through history needs
|
||||
/// fewer round trips.
|
||||
const _kOlderPageSize = 200;
|
||||
|
||||
@@ -11,6 +11,8 @@ abstract class ChatState with _$ChatState {
|
||||
@Default('') String currentToken,
|
||||
GetChatResponse? chatResponse,
|
||||
int? referenceMessageId,
|
||||
@Default(true) bool hasMoreOld,
|
||||
@Default(false) bool isLoadingOlder,
|
||||
}) = _ChatState;
|
||||
|
||||
factory ChatState.fromJson(Map<String, Object?> json) =>
|
||||
|
||||
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$ChatState {
|
||||
|
||||
String get currentToken; GetChatResponse? get chatResponse; int? get referenceMessageId;
|
||||
String get currentToken; GetChatResponse? get chatResponse; int? get referenceMessageId; bool get hasMoreOld; bool get isLoadingOlder;
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -28,16 +28,16 @@ $ChatStateCopyWith<ChatState> get copyWith => _$ChatStateCopyWithImpl<ChatState>
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId)&&(identical(other.hasMoreOld, hasMoreOld) || other.hasMoreOld == hasMoreOld)&&(identical(other.isLoadingOlder, isLoadingOlder) || other.isLoadingOlder == isLoadingOlder));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId);
|
||||
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId,hasMoreOld,isLoadingOlder);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId)';
|
||||
return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId, hasMoreOld: $hasMoreOld, isLoadingOlder: $isLoadingOlder)';
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ abstract mixin class $ChatStateCopyWith<$Res> {
|
||||
factory $ChatStateCopyWith(ChatState value, $Res Function(ChatState) _then) = _$ChatStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String currentToken, GetChatResponse? chatResponse, int? referenceMessageId
|
||||
String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder
|
||||
});
|
||||
|
||||
|
||||
@@ -65,12 +65,14 @@ class _$ChatStateCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,Object? hasMoreOld = null,Object? isLoadingOlder = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable
|
||||
as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable
|
||||
as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
as int?,hasMoreOld: null == hasMoreOld ? _self.hasMoreOld : hasMoreOld // ignore: cast_nullable_to_non_nullable
|
||||
as bool,isLoadingOlder: null == isLoadingOlder ? _self.isLoadingOlder : isLoadingOlder // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -155,10 +157,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatState() when $default != null:
|
||||
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _:
|
||||
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId,_that.hasMoreOld,_that.isLoadingOlder);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -176,10 +178,10 @@ return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatState():
|
||||
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _:
|
||||
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId,_that.hasMoreOld,_that.isLoadingOlder);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
@@ -196,10 +198,10 @@ return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatState() when $default != null:
|
||||
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _:
|
||||
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId,_that.hasMoreOld,_that.isLoadingOlder);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -211,12 +213,14 @@ return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);
|
||||
@JsonSerializable()
|
||||
|
||||
class _ChatState implements ChatState {
|
||||
const _ChatState({this.currentToken = '', this.chatResponse, this.referenceMessageId});
|
||||
const _ChatState({this.currentToken = '', this.chatResponse, this.referenceMessageId, this.hasMoreOld = true, this.isLoadingOlder = false});
|
||||
factory _ChatState.fromJson(Map<String, dynamic> json) => _$ChatStateFromJson(json);
|
||||
|
||||
@override@JsonKey() final String currentToken;
|
||||
@override final GetChatResponse? chatResponse;
|
||||
@override final int? referenceMessageId;
|
||||
@override@JsonKey() final bool hasMoreOld;
|
||||
@override@JsonKey() final bool isLoadingOlder;
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@@ -231,16 +235,16 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId)&&(identical(other.hasMoreOld, hasMoreOld) || other.hasMoreOld == hasMoreOld)&&(identical(other.isLoadingOlder, isLoadingOlder) || other.isLoadingOlder == isLoadingOlder));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId);
|
||||
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId,hasMoreOld,isLoadingOlder);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId)';
|
||||
return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId, hasMoreOld: $hasMoreOld, isLoadingOlder: $isLoadingOlder)';
|
||||
}
|
||||
|
||||
|
||||
@@ -251,7 +255,7 @@ abstract mixin class _$ChatStateCopyWith<$Res> implements $ChatStateCopyWith<$Re
|
||||
factory _$ChatStateCopyWith(_ChatState value, $Res Function(_ChatState) _then) = __$ChatStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String currentToken, GetChatResponse? chatResponse, int? referenceMessageId
|
||||
String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder
|
||||
});
|
||||
|
||||
|
||||
@@ -268,12 +272,14 @@ class __$ChatStateCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,Object? hasMoreOld = null,Object? isLoadingOlder = null,}) {
|
||||
return _then(_ChatState(
|
||||
currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable
|
||||
as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable
|
||||
as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
as int?,hasMoreOld: null == hasMoreOld ? _self.hasMoreOld : hasMoreOld // ignore: cast_nullable_to_non_nullable
|
||||
as bool,isLoadingOlder: null == isLoadingOlder ? _self.isLoadingOlder : isLoadingOlder // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ _ChatState _$ChatStateFromJson(Map<String, dynamic> json) => _ChatState(
|
||||
? null
|
||||
: GetChatResponse.fromJson(json['chatResponse'] as Map<String, dynamic>),
|
||||
referenceMessageId: (json['referenceMessageId'] as num?)?.toInt(),
|
||||
hasMoreOld: json['hasMoreOld'] as bool? ?? true,
|
||||
isLoadingOlder: json['isLoadingOlder'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ChatStateToJson(_ChatState instance) =>
|
||||
@@ -19,4 +21,6 @@ Map<String, dynamic> _$ChatStateToJson(_ChatState instance) =>
|
||||
'currentToken': instance.currentToken,
|
||||
'chatResponse': instance.chatResponse,
|
||||
'referenceMessageId': instance.referenceMessageId,
|
||||
'hasMoreOld': instance.hasMoreOld,
|
||||
'isLoadingOlder': instance.isLoadingOlder,
|
||||
};
|
||||
|
||||
@@ -42,9 +42,15 @@ class ChatView extends StatefulWidget {
|
||||
|
||||
class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||
final ItemPositionsListener _positionsListener =
|
||||
ItemPositionsListener.create();
|
||||
final TextEditingController _searchTextController = TextEditingController();
|
||||
final Map<int, int> _matchIndices = {};
|
||||
|
||||
// Number of rows currently rendered; kept in sync in build so the scroll
|
||||
// listener can tell when the oldest row (highest index, reverse list) nears.
|
||||
int _itemCount = 0;
|
||||
|
||||
bool _searchActive = false;
|
||||
String _searchQuery = '';
|
||||
List<ChatSearchMatch> _matches = const [];
|
||||
@@ -63,9 +69,24 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
super.initState();
|
||||
_chatBlocRef = context.read<ChatBloc>();
|
||||
_chatListBlocRef = context.read<ChatListBloc>();
|
||||
_positionsListener.itemPositions.addListener(_onScrollPositions);
|
||||
NotificationTasks.clearNotificationsForChat(widget.room.token);
|
||||
}
|
||||
|
||||
/// Loads the next older block once the top of the list comes into view.
|
||||
void _onScrollPositions() {
|
||||
final positions = _positionsListener.itemPositions.value;
|
||||
if (positions.isEmpty) return;
|
||||
// reverse:true → the highest index is the oldest message (top of screen).
|
||||
// Prefetch ~a screenful ahead so the next block is usually already merged
|
||||
// before the user reaches the top — the load stays invisible.
|
||||
final maxIndex = positions.map((p) => p.index).reduce(math.max);
|
||||
if (maxIndex < _itemCount - _kLoadOlderPrefetchRows) return;
|
||||
final data = _chatBlocRef?.state.data;
|
||||
if (data == null || !data.hasMoreOld || data.isLoadingOlder) return;
|
||||
_chatBlocRef?.loadOlder();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -93,6 +114,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
if (_subscribedRoute != null) {
|
||||
AppRoutes.chatRouteObserver.unsubscribe(this);
|
||||
}
|
||||
_positionsListener.itemPositions.removeListener(_onScrollPositions);
|
||||
_markAsReadFinal();
|
||||
_chatBlocRef?.leaveChat(widget.room.token);
|
||||
_searchTextController.dispose();
|
||||
@@ -281,22 +303,6 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
);
|
||||
}
|
||||
|
||||
if (response.data.length >= 200) {
|
||||
messages.insert(
|
||||
0,
|
||||
ChatBubble(
|
||||
isSender: false,
|
||||
bubbleData: GetChatResponseObject.getTextDummy(
|
||||
'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. '
|
||||
'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de',
|
||||
),
|
||||
chatData: widget.room,
|
||||
refetch: ({bool renew = false}) => _refresh(),
|
||||
),
|
||||
);
|
||||
chronologicalMatchIndex.updateAll((_, v) => v + 1);
|
||||
}
|
||||
|
||||
final total = messages.length;
|
||||
_matchIndices
|
||||
..clear()
|
||||
@@ -373,9 +379,27 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
final items = _buildMessages(
|
||||
state.chatResponse!,
|
||||
).reversed.toList();
|
||||
// reverse:true renders index 0 at the bottom, so the top
|
||||
// marker (spinner / start-of-chat) goes at the end.
|
||||
if (state.isLoadingOlder) {
|
||||
items.add(const _LoadingOlderIndicator());
|
||||
} else if (!state.hasMoreOld) {
|
||||
items.add(
|
||||
ChatBubble(
|
||||
isSender: false,
|
||||
bubbleData: GetChatResponseObject.getTextDummy(
|
||||
'Anfang des Chats',
|
||||
),
|
||||
chatData: widget.room,
|
||||
refetch: ({bool renew = false}) => _refresh(),
|
||||
),
|
||||
);
|
||||
}
|
||||
_itemCount = items.length;
|
||||
return ScrollablePositionedList.builder(
|
||||
reverse: true,
|
||||
itemScrollController: _itemScrollController,
|
||||
itemPositionsListener: _positionsListener,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (ctx, idx) => items[idx],
|
||||
);
|
||||
@@ -405,3 +429,22 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// How many rows before the oldest one the older-history prefetch kicks in.
|
||||
const _kLoadOlderPrefetchRows = 15;
|
||||
|
||||
class _LoadingOlderIndicator extends StatelessWidget {
|
||||
const _LoadingOlderIndicator();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user