improved performance and battery usage on older devices

This commit is contained in:
2026-09-25 23:58:08 +02:00
parent ed8e52f475
commit 56a497985b
70 changed files with 1631 additions and 621 deletions
+27 -18
View File
@@ -75,6 +75,17 @@ class _ChatListViewState extends State<_ChatListView> {
@override
Widget build(BuildContext context) {
final bloc = context.read<ChatListBloc>();
// Selected here, not in the LoadableStateConsumer child: that closure runs
// during the consumer's build, where this context may not subscribe.
// Draft keys are part of the selection so the draft marker follows; the
// draft text itself is saved without notifying.
final (favoritesToTop, unreadToTop, _) = context.select(
(SettingsCubit c) => (
c.state.talkSettings.sortFavoritesToTop,
c.state.talkSettings.sortUnreadToTop,
c.state.talkSettings.drafts.keys.join('|'),
),
);
return SplitView.material(
placeholder: const SplitViewPlaceholder(),
breakpoint: 1000,
@@ -129,13 +140,9 @@ class _ChatListViewState extends State<_ChatListView> {
final rooms = state.rooms;
if (rooms == null) return const SizedBox.shrink();
final talkSettings = context
.watch<SettingsCubit>()
.val()
.talkSettings;
final sorted = rooms.sortBy(
favoritesToTop: talkSettings.sortFavoritesToTop,
unreadToTop: talkSettings.sortUnreadToTop,
favoritesToTop: favoritesToTop,
unreadToTop: unreadToTop,
);
if (sorted.isEmpty) {
@@ -145,23 +152,25 @@ class _ChatListViewState extends State<_ChatListView> {
);
}
return ListView(
final drafts = _settings.val().talkSettings.drafts;
final indexByToken = {
for (var i = 0; i < sorted.length; i++) sorted[i].token: i,
};
return ListView.builder(
padding: EdgeInsets.zero,
children: sorted.map((room) {
final hasDraft = _settings
.val()
.talkSettings
.drafts
.containsKey(room.token);
// Stable key keeps element identity across re-sorts so the
// inner UserAvatar reuses its cached bytes instead of
// flashing on every list update.
itemCount: sorted.length,
// Keeps each tile's state (cached avatar bytes) with its room
// when a re-sort moves it to another index.
findChildIndexCallback: (key) =>
indexByToken[(key as ValueKey<String>).value],
itemBuilder: (context, index) {
final room = sorted[index];
return ChatTile(
key: ValueKey(room.token),
data: room,
hasDraft: hasDraft,
hasDraft: drafts.containsKey(room.token),
);
}).toList(),
},
);
},
),
+90 -32
View File
@@ -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,
);
}
}
+37 -13
View File
@@ -59,6 +59,13 @@ class _ChatBubbleState extends State<ChatBubble>
with SingleTickerProviderStateMixin, DownloadTrigger<ChatBubble> {
late ChatMessage message;
// The parsed message and its widget are only rebuilt when their inputs
// change. The bubble itself rebuilds on every chat emit, swipe frame and
// keyboard frame; re-running rich-object parsing, linkify/Markdown and emoji
// detection each time is what makes long chats stutter.
Object? _messageKey;
late Widget _messageWidget;
Offset _position = Offset.zero;
Offset _dragStartPosition = Offset.zero;
bool _swipeActionArmed = false;
@@ -185,12 +192,34 @@ class _ChatBubbleState extends State<ChatBubble>
}
}
@override
Widget build(BuildContext context) {
void _updateMessage(BuildContext context) {
final style = _messageTextStyle(context);
final renderMarkdown =
widget.bubbleData.markdown &&
widget.bubbleData.messageType ==
GetRoomResponseObjectMessageType.comment;
final key = (
widget.bubbleData,
widget.highlightQuery,
style,
renderMarkdown,
);
if (key == _messageKey) return;
_messageKey = key;
message = ChatMessage(
originalMessage: widget.bubbleData.message,
originalData: widget.bubbleData.messageParameters,
);
_messageWidget = message.getWidget(
highlightQuery: widget.highlightQuery,
style: style,
renderMarkdown: renderMarkdown,
);
}
@override
Widget build(BuildContext context) {
_updateMessage(context);
final showActorDisplayName =
_rendersAsCommentBubble &&
widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
@@ -277,14 +306,7 @@ class _ChatBubbleState extends State<ChatBubble>
actorText: actorText,
actorWidget: actorWidget,
timeText: timeText,
messageWidget: message.getWidget(
highlightQuery: widget.highlightQuery,
style: _messageTextStyle(context),
renderMarkdown:
widget.bubbleData.markdown &&
widget.bubbleData.messageType ==
GetRoomResponseObjectMessageType.comment,
),
messageWidget: _messageWidget,
parent: parent,
bubbleData: widget.bubbleData,
isSender: widget.isSender,
@@ -350,10 +372,12 @@ class _BubbleContent extends StatelessWidget {
Widget build(BuildContext context) => MergeSemantics(
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.9,
maxWidth: MediaQuery.sizeOf(context).width * 0.9,
minWidth: showActorDisplayName
? actorText.size.width
: timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3,
? actorText.measuredWidth(context)
: timeText.measuredWidth(context) +
(isSender ? spacing + timeIconSize : 0) +
3,
),
child: Stack(
children: [
@@ -34,7 +34,7 @@ class ChatBubbleReactions extends StatelessWidget {
return Transform.translate(
offset: const Offset(0, -10),
child: Container(
width: MediaQuery.of(context).size.width,
width: MediaQuery.sizeOf(context).width,
margin: const EdgeInsets.only(left: 15, right: 15),
child: Wrap(
alignment: isSender ? WrapAlignment.end : WrapAlignment.start,
+24 -11
View File
@@ -83,12 +83,20 @@ class _ChatTextfieldState extends State<ChatTextfield> {
);
}
/// Called per keystroke, so the text itself is saved silently; only a draft
/// appearing or disappearing notifies listeners (the chat list's marker).
void _setDraft(String text) {
final talkSettings = settings.val(write: true).talkSettings;
final drafts = settings.val().talkSettings.drafts;
final hadDraft = drafts.containsKey(widget.sendToToken);
if (text.isNotEmpty) {
talkSettings.drafts[widget.sendToToken] = text;
drafts[widget.sendToToken] = text;
} else {
talkSettings.drafts.removeWhere((key, _) => key == widget.sendToToken);
drafts.remove(widget.sendToToken);
}
if (hadDraft != text.isNotEmpty) {
settings.val(write: true);
} else {
settings.saveSilently();
}
}
@@ -276,17 +284,22 @@ class _ChatTextfieldState extends State<ChatTextfield> {
@override
Widget build(BuildContext context) {
final chatBloc = context.watch<ChatBloc>();
final chatState = chatBloc.state.data;
final chatBloc = context.read<ChatBloc>();
// Only the reply reference is rendered from the chat state; loading flags
// and paging emits don't need to rebuild the input.
final (referenceMessageId, chatResponse) = context.select(
(ChatBloc b) => (
b.state.data?.referenceMessageId,
b.state.data?.chatResponse,
),
);
Widget replyBanner = const SizedBox.shrink();
if (chatState != null &&
chatState.referenceMessageId != null &&
chatState.chatResponse != null) {
if (referenceMessageId != null && chatResponse != null) {
try {
final referenceMessage = chatState.chatResponse!
.sortByTimestamp()
.firstWhere((e) => e.id == chatState.referenceMessageId);
final referenceMessage = chatResponse.data.firstWhere(
(e) => e.id == referenceMessageId,
);
replyBanner = Row(
children: [
Expanded(
+17 -3
View File
@@ -68,15 +68,25 @@ class _ChatTileState extends State<ChatTile> {
/// One-line preview of the last message: rich-object placeholders resolved,
/// newlines flattened and — for Markdown messages — formatting stripped so
/// the list shows readable text rather than raw markers.
///
/// Memoised per last message: the tile rebuilds on every chat-list emit and
/// the Markdown strip is a full parse. Keyed by content, since every refresh
/// delivers new message objects.
String _lastMessagePreview() {
final last = widget.data.lastMessage;
final key = (last.id, last.message, last.markdown);
if (key == _previewFor) return _preview;
final text = RichObjectStringProcessor.parseToString(
last.message.replaceAll('\n', ' '),
last.messageParameters,
);
return last.markdown ? markdownToPlainText(text) : text;
_previewFor = key;
return _preview = last.markdown ? markdownToPlainText(text) : text;
}
Object? _previewFor;
String _preview = '';
Future<void> _setCurrentAsRead() async {
final token = widget.data.token;
final lastId = widget.data.lastMessage.id;
@@ -89,7 +99,11 @@ class _ChatTileState extends State<ChatTile> {
@override
Widget build(BuildContext context) {
final chatBloc = context.watch<ChatBloc>();
// Only the open token matters here (split-view highlight); watching the
// whole bloc rebuilt every tile on each message of the open chat.
final currentToken = context.select(
(ChatBloc b) => b.state.data?.currentToken,
);
final isGroup =
widget.data.type != GetRoomResponseObjectConversationType.oneToOne;
final circleAvatar = UserAvatar(
@@ -100,7 +114,7 @@ class _ChatTileState extends State<ChatTile> {
return ListTile(
style: ListTileStyle.list,
tileColor:
chatBloc.state.data?.currentToken == widget.data.token &&
currentToken == widget.data.token &&
TalkNavigator.isSecondaryVisible(context)
? Theme.of(context).primaryColor.withAlpha(100)
: null,