improved performance and battery usage on older devices
This commit is contained in:
@@ -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(),
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user