improved performance and battery usage on older devices
This commit is contained in:
@@ -14,6 +14,7 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con
|
||||
import '../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../../../state/app/modules/chat/bloc/chat_state.dart';
|
||||
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import '../../../utils/debouncer.dart';
|
||||
import '../../../widget/chat_background.dart';
|
||||
import '../../../widget/clickable_app_bar.dart';
|
||||
import '../../../widget/user_avatar.dart';
|
||||
@@ -118,6 +119,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
_markAsReadFinal();
|
||||
_chatBlocRef?.leaveChat(widget.room.token);
|
||||
_searchTextController.dispose();
|
||||
Debouncer.cancel(_searchDebounceTag);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -150,6 +152,45 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
context.read<ChatBloc>().setToken(widget.room.token);
|
||||
}
|
||||
|
||||
void _refetch({bool renew = false}) => _refresh();
|
||||
|
||||
// The built rows only depend on the chat data and the search state; the list
|
||||
// itself rebuilds far more often (loading flips, keyboard, parent rebuilds),
|
||||
// and re-sorting plus re-creating every bubble each time is wasted work.
|
||||
Object? _itemsKey;
|
||||
List<Widget> _items = const [];
|
||||
|
||||
List<Widget> _itemsFor(ChatState state) {
|
||||
final key = (
|
||||
state.chatResponse,
|
||||
state.isLoadingOlder,
|
||||
state.hasMoreOld,
|
||||
_searchActive,
|
||||
_searchQuery,
|
||||
_activeMatchIndex,
|
||||
widget.room,
|
||||
);
|
||||
if (key == _itemsKey) return _items;
|
||||
_itemsKey = key;
|
||||
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(
|
||||
key: const ValueKey('chat-start'),
|
||||
isSender: false,
|
||||
bubbleData: GetChatResponseObject.getTextDummy('Anfang des Chats'),
|
||||
chatData: widget.room,
|
||||
refetch: _refetch,
|
||||
),
|
||||
);
|
||||
}
|
||||
return _items = items;
|
||||
}
|
||||
|
||||
void _enterSearchMode() {
|
||||
setState(() {
|
||||
_searchActive = true;
|
||||
@@ -163,6 +204,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
}
|
||||
|
||||
void _exitSearchMode() {
|
||||
Debouncer.cancel(_searchDebounceTag);
|
||||
setState(() {
|
||||
_searchActive = false;
|
||||
_searchQuery = '';
|
||||
@@ -175,7 +217,22 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
});
|
||||
}
|
||||
|
||||
late final String _searchDebounceTag =
|
||||
'chat-search-${identityHashCode(this)}';
|
||||
|
||||
/// Matching re-scans the whole history and rebuilds every row, so it runs
|
||||
/// once typing pauses instead of per keystroke.
|
||||
void _onSearchChanged(String q) {
|
||||
Debouncer.debounce(
|
||||
_searchDebounceTag,
|
||||
const Duration(milliseconds: 200),
|
||||
() {
|
||||
if (mounted) _applySearch(q);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _applySearch(String q) {
|
||||
final chatResponse = context.read<ChatBloc>().state.data?.chatResponse;
|
||||
setState(() {
|
||||
_searchQuery = q;
|
||||
@@ -268,9 +325,13 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
messages.add(
|
||||
ChatBubble(
|
||||
isSender: false,
|
||||
key: ValueKey(
|
||||
'day-${elementDate.year}-${elementDate.month}-'
|
||||
'${elementDate.day}',
|
||||
),
|
||||
bubbleData: GetChatResponseObject.getDateDummy(element.timestamp),
|
||||
chatData: widget.room,
|
||||
refetch: ({bool renew = false}) => _refresh(),
|
||||
refetch: _refetch,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -286,6 +347,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
|
||||
messages.add(
|
||||
ChatBubble(
|
||||
key: ValueKey(element.id),
|
||||
isSender:
|
||||
element.actorId == widget.selfId &&
|
||||
(element.messageType ==
|
||||
@@ -294,7 +356,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
GetRoomResponseObjectMessageType.deletedComment),
|
||||
bubbleData: element,
|
||||
chatData: widget.room,
|
||||
refetch: ({bool renew = false}) => _refresh(),
|
||||
refetch: _refetch,
|
||||
isRead: element.id <= commonRead,
|
||||
selfId: widget.selfId,
|
||||
highlightQuery: highlightQuery,
|
||||
@@ -317,17 +379,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Swallow the first back gesture while the keyboard is visible so it
|
||||
// dismisses the IME instead of popping the chat — matches platform UX
|
||||
// expectations (e.g. WhatsApp/Telegram) and prevents accidental exits
|
||||
// mid-typing.
|
||||
final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0;
|
||||
return PopScope(
|
||||
canPop: !keyboardOpen,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
return _KeyboardDismissPopScope(
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xffefeae2),
|
||||
appBar: _searchActive
|
||||
@@ -376,25 +428,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
state.currentToken == widget.room.token,
|
||||
enablePullToRefresh: false,
|
||||
child: (state, _) {
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
final items = _itemsFor(state);
|
||||
_itemCount = items.length;
|
||||
return ScrollablePositionedList.builder(
|
||||
reverse: true,
|
||||
@@ -448,3 +482,27 @@ class _LoadingOlderIndicator extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Swallows the first back gesture while the keyboard is visible so it
|
||||
/// dismisses the IME instead of popping the chat — matches platform UX
|
||||
/// expectations (e.g. WhatsApp/Telegram) and prevents accidental exits
|
||||
/// mid-typing. A separate widget so the per-frame inset changes during the
|
||||
/// keyboard animation only rebuild this scope, not the whole chat.
|
||||
class _KeyboardDismissPopScope extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _KeyboardDismissPopScope({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0;
|
||||
return PopScope(
|
||||
canPop: !keyboardOpen,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user