Compare commits
4 commits
main
...
tc/chat-pe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86afc4a6c8 | ||
|
|
16f2722a5b | ||
|
|
979f46edfe | ||
|
|
57e7f73aa8 |
12 changed files with 456 additions and 49 deletions
93
docs/chat-perf-backlog.md
Normal file
93
docs/chat-perf-backlog.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Chat performance backlog
|
||||||
|
|
||||||
|
Findings from a chat-rendering performance audit (2026-07), verified against the pinned Flutter 3.44.2 SDK source and installed package sources. The top-ranked confirmed items were fixed on `tc/chat-perf`; this file tracks what remains, ranked by expected impact. Line references are to the SDK/package versions pinned at audit time.
|
||||||
|
|
||||||
|
## Fixed in the initial pass (for context)
|
||||||
|
|
||||||
|
- `findChildIndexCallback` on both chat ListViews. Per-item `ObjectKey`s without it forced deactivate + re-inflate of every built row on every 200 ms flush (`SliverMultiBoxAdaptorElement.performRebuild` matches purely by position when `findIndexByKey` returns null; key mismatch at each shifted index lands in the remount branch of `Element.updateChild`, framework.dart:4047).
|
||||||
|
- Per-message widget caching (`Expando` in `chat.dart`). Identical widget instances short-circuit `Element.updateChild` (framework.dart:4014), so a flush only builds genuinely new messages instead of re-running `generateSpan` for every visible row. Invalidated via `IRCMessage.renderRevision` (bumped by the in-place CLEARCHAT/CLEARMSG mutations).
|
||||||
|
- `memCacheHeight` for emotes and badges (decode at display size instead of the CDN's 3x-4x intrinsic size; verified animation-safe: `ResizeImage` applies the target size to the codec, all frames included).
|
||||||
|
- `TickerMode(enabled: false)` on inactive `IndexedStack` tabs (the `Image` widget pauses animated codecs when TickerMode is off, image.dart:1151; IndexedStack's own `Visibility(maintainAnimation: true)` wrapper never disables tickers).
|
||||||
|
- Flush timers pause while the app is backgrounded (`onAppPaused`/`onAppResumed` through `ChatTabsStore` -> each tab's `ChatStore` + the merged render timer). Frames stop when paused (`SchedulerBinding.framesEnabled`) but Dart timers don't, so the 5 Hz wakeups were pure battery cost.
|
||||||
|
- Startup orphaned-cache sweep switched from `listSync` (synchronous I/O on the UI isolate, up to ~10k files) to async `list()`.
|
||||||
|
- `adjustChatNameColor` memoized keyed by (color, background, target contrast).
|
||||||
|
|
||||||
|
## Remaining items, ranked
|
||||||
|
|
||||||
|
### 1. "Animate emotes" setting (first-frame / static mode)
|
||||||
|
|
||||||
|
The biggest remaining battery lever. Every mounted animated emote keeps a decode -> `scheduleFrameCallback` loop running indefinitely while any listener is attached (cached_network_image ships its own copy of the SDK's `MultiFrameImageStreamCompleter` with identical behavior: `multi_image_stream_completer.dart:165`). "Mounted" includes rows inside the 500 px `scrollCacheExtent`, not just visible ones. The engine never idles at vsync while a busy chat is on screen. OS reduce-motion already pauses this (Image honors `MediaQuery.disableAnimations`), and inactive tabs now pause via TickerMode, but the visible tab still animates dozens of emotes continuously.
|
||||||
|
|
||||||
|
Options: a setting that requests static CDN variants (7TV/BTTV/FFZ all serve static URLs), or a one-frame custom ImageProvider. Effort: M-L. User-visible setting.
|
||||||
|
|
||||||
|
### 2. Disconnect IRC while backgrounded (Android)
|
||||||
|
|
||||||
|
Flush timers now pause, but every IRC line is still received and parsed while backgrounded (50+ msg/s in busy channels = continuous CPU + radio with zero visible output; on iOS the OS suspends the runtime anyway, so this is Android + iOS-PiP). Twitch's own app disconnects chat in background. Disconnect after a grace period, reconnect on resume, optionally backfill via recent-messages when the setting is on. Behavior-visible: a scrollback gap while backgrounded. Effort: M.
|
||||||
|
|
||||||
|
### 3. Skip flushing inactive tabs
|
||||||
|
|
||||||
|
Offstage IndexedStack tabs still lay out and rebuild their lists on every 200 ms flush (builds are cheap now with widget caching, but `RenderStack.performLayout` is O(N) over all children, stack.dart:678, and each offstage viewport lays out its cache-extent rows). Gate `addMessages` for non-active tabs; the buffer overflow drain already bounds growth. Must keep flushing when `mergedMode` is on (merged view reads each tab's `messages`), and `hasUnreadMessages` should read the buffer too. Effort: M.
|
||||||
|
|
||||||
|
### 4. BTTV badge SVGs bypass the disk cache
|
||||||
|
|
||||||
|
`SvgPicture.network` (irc.dart `_createBadgeWidget`) uses plain http with a ref-counted in-memory picture cache (flutter_svg 2.3.0 loaders.dart:436; vector_graphics `_livePictureCache` evicts when the last widget goes away). Badges re-download every session and can re-fetch within a session. Route bytes through `CustomCacheManager` and feed `SvgPicture.string`/a bytes loader. Effort: S-M.
|
||||||
|
|
||||||
|
### 5. Merged-mode dirty check
|
||||||
|
|
||||||
|
`_refreshMergedMessages` collects + sorts up to tabs x 100 items and assigns a new list identity every 200 ms tick even when no new messages arrived, re-firing the merged list observer at a constant 5 Hz. Track per-tab message counts and short-circuit when unchanged. Effort: S.
|
||||||
|
|
||||||
|
### 6. Trim IRCMessage memory (drop `raw` retention)
|
||||||
|
|
||||||
|
Each message retains the full raw IRC line (0.5-1 KB) plus substring copies in `tags`, the `split` word list, and an unconditionally allocated (usually empty) `localEmotes` map: roughly 1.5-3 KB per message, ~8-15 MB per tab at the 5000 cap, ~100 MB order across 10 busy tabs. Verify nothing consumes `raw` after parse and drop or lazily derive it; skip allocating empty `localEmotes`. Effort: S-M.
|
||||||
|
|
||||||
|
### 7. Dispose TapGestureRecognizers from generateSpan
|
||||||
|
|
||||||
|
`TextSpan.recognizer` contract says the owner must dispose (text_span.dart:124), and nothing does. Verified low severity: an idle recognizer holds no timers, arena entries, or router routes (those attach on pointer-down only), so undisposed ones are plain GC-able garbage, and widget caching now creates them once per message instead of per rebuild. Remaining real defect: a row rebuild between pointer-down and up swaps recognizers and silently drops that tap. Proper fix needs recognizer ownership with a dispose hook (stateful row or registry keyed to message eviction). Effort: M.
|
||||||
|
|
||||||
|
### 8. Lighter tap targets than per-emote InkWell
|
||||||
|
|
||||||
|
Each emote/badge InkWell eagerly builds ~7 widgets, a FocusNode, and a global focus-highlight listener (ink_well.dart:929, build at :1386); Tooltips on historical/shared-chat badges each add a global pointer route consulted on every pointer event (raw_tooltip.dart:793). Mostly a first-build cost now that rows are cached; if profiling still shows element bloat, swap emotes to `GestureDetector` (loses the ripple, which never realistically fires in chat). Effort: S.
|
||||||
|
|
||||||
|
### 9. memCache sizing for remaining image sites
|
||||||
|
|
||||||
|
Not yet covered: the shared-chat source avatar (raw `CachedNetworkImage` with `imageBuilder` in irc.dart, needs verification of how memCacheWidth interacts with imageBuilder's provider), `ProfilePicture`, and the emote menu panels. Effort: S.
|
||||||
|
|
||||||
|
### 10. Adaptive CDN density selection
|
||||||
|
|
||||||
|
Emote URLs still request max density (Twitch `3.0`, BTTV `3x`, FFZ `4x`, 7TV largest non-AVIF) and rely on decode-time downscaling. Picking the 2x variant when DPR x display-size allows would also save bandwidth and disk. Tradeoff: cache churn when `emoteScale` changes. 7TV note: dims come from `files.first` (1x) while the URL is the largest file; that is intentional (layout at 1x size), keep it. Effort: M.
|
||||||
|
|
||||||
|
### 11. Parse-path micro-optimizations
|
||||||
|
|
||||||
|
All parse-time (once per message, not per frame), verified minor: per-grapheme `regexEmoji.hasMatch` loop in `IRCMessage.fromString` (single-pass scan instead), blocked-users linear list scan per message (use a Set of user ids), muted-words O(words x patterns). Effort: S.
|
||||||
|
|
||||||
|
### 12. errorWidget for failed images
|
||||||
|
|
||||||
|
No `errorWidget`/`errorBuilder` anywhere in lib; failed emote/badge/avatar loads render as silent blanks. Hygiene + debuggability. Effort: S.
|
||||||
|
|
||||||
|
### 13. Screen-reader story for the chat list
|
||||||
|
|
||||||
|
With a screen reader on, a 5 Hz-updating list of WidgetSpan-heavy rows is a semantics hazard (each emote is an unlabeled node; constant re-announcement). Semantics are compiled only when assistive tech is active (object.dart:1397), so this costs nothing today, but consider per-row `Semantics(label:)` flattening and update throttling when semantics are enabled. Effort: M-L.
|
||||||
|
|
||||||
|
### 14. Measurement follow-ups
|
||||||
|
|
||||||
|
- Profile-mode DevTools timeline during a busy raid chat: UI thread (span builds should now be flat between new messages) and raster thread (animated emote layers).
|
||||||
|
- Memory view: ImageCache pressure before/after memCacheHeight (entry count is the binding cap: 1000 images / 100 MiB defaults, image_cache.dart:18).
|
||||||
|
- Android `dumpsys batterystats` / Xcode Energy gauge for the background and animated-emote items.
|
||||||
|
- A benchmark harness feeding recorded IRC traffic through `ChatStore` would make regressions visible in CI; nothing covers chat rendering today.
|
||||||
|
|
||||||
|
## Existing optimizations: verified verdicts (do not relitigate)
|
||||||
|
|
||||||
|
| Optimization | Verdict |
|
||||||
|
| --- | --- |
|
||||||
|
| 200 ms flush batching | KEEP (right pattern; interval fine) |
|
||||||
|
| Render cap 100 while autoscrolling, 5000 buffer + 20% trim | KEEP (trim realloc is per-event, cheap; see item 6 for the per-message footprint) |
|
||||||
|
| `ObjectKey(ircMessage)` on rows | KEEP, now paired with `findChildIndexCallback` (alone it was actively harmful: forced remounts) |
|
||||||
|
| `addAutomaticKeepAlives: false` | KEEP (micro; known tradeoff: in-flight ink splash can be culled, SelectionArea rows lose keep-alive) |
|
||||||
|
| `scrollCacheExtent: 500 px` | KEEP (default is 250, applied both sides; revisit only alongside item 1 since it doubles the live animated-emote set) |
|
||||||
|
| Disk cache 10k objects / 30 d | KEEP (cleanup is lazy, debounced, SQL-side, bounded to 200 deletes per pass) |
|
||||||
|
| `useFade: false` + const placeholders on emotes | KEEP |
|
||||||
|
| `cacheKey` on FrostyCachedNetworkImage | KEEP (live for stream thumbnails' 5-min busting; unused in chat paths by design) |
|
||||||
|
| ProfilePicture static URL cache | KEEP (unbounded but tiny; known staleness until app restart) |
|
||||||
|
| IndexedStack tab keep-alive | KEEP, now paired with TickerMode gating |
|
||||||
|
| Per-message `Opacity` fades | KEEP (compositing layer, not saveLayer; static opacity is cheap and adds a repaint boundary) |
|
||||||
|
| Muted-word pre-normalization, interaction pause, delayed-callback queue, 60 s socket ping | KEEP |
|
||||||
|
|
@ -23,7 +23,10 @@ class CustomCacheManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final allFiles = cacheDir.listSync(recursive: true).toList();
|
// Async listing — the cache can hold up to maxNrOfCacheObjects (10k)
|
||||||
|
// files, and a synchronous recursive listing that large would block the
|
||||||
|
// UI isolate during startup.
|
||||||
|
final allFiles = await cacheDir.list(recursive: true).toList();
|
||||||
|
|
||||||
await _repo.open();
|
await _repo.open();
|
||||||
final cachedObjects = await _repo.getAllObjects();
|
final cachedObjects = await _repo.getAllObjects();
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,12 @@ class IRCMessage {
|
||||||
final VoidCallback? actionCallback;
|
final VoidCallback? actionCallback;
|
||||||
final String? actionLabel;
|
final String? actionLabel;
|
||||||
|
|
||||||
|
/// Bumped whenever the message is mutated in place after creation (the
|
||||||
|
/// moderation marking in [clearChat]/[clearMessage]), so render output
|
||||||
|
/// cached against this message's identity knows to invalidate. Purely a
|
||||||
|
/// render-cache signal — not part of the IRC model.
|
||||||
|
int renderRevision = 0;
|
||||||
|
|
||||||
IRCMessage({
|
IRCMessage({
|
||||||
required this.raw,
|
required this.raw,
|
||||||
required this.command,
|
required this.command,
|
||||||
|
|
@ -85,6 +91,7 @@ class IRCMessage {
|
||||||
if (message.user == bannedUser) {
|
if (message.user == bannedUser) {
|
||||||
// Mark the message for removal.
|
// Mark the message for removal.
|
||||||
messages[i].command = Command.clearChat;
|
messages[i].command = Command.clearChat;
|
||||||
|
messages[i].renderRevision++;
|
||||||
|
|
||||||
// If timed-out, indicate the duration.
|
// If timed-out, indicate the duration.
|
||||||
if (banDuration != null) {
|
if (banDuration != null) {
|
||||||
|
|
@ -98,6 +105,7 @@ class IRCMessage {
|
||||||
if (bufferedMessage.user == bannedUser) {
|
if (bufferedMessage.user == bannedUser) {
|
||||||
// Mark the message for removal.
|
// Mark the message for removal.
|
||||||
bufferedMessages[i].command = Command.clearChat;
|
bufferedMessages[i].command = Command.clearChat;
|
||||||
|
bufferedMessages[i].renderRevision++;
|
||||||
|
|
||||||
// If timed-out, indicate the duration.
|
// If timed-out, indicate the duration.
|
||||||
if (banDuration != null) {
|
if (banDuration != null) {
|
||||||
|
|
@ -119,6 +127,7 @@ class IRCMessage {
|
||||||
for (var i = 0; i < messages.length; i++) {
|
for (var i = 0; i < messages.length; i++) {
|
||||||
if (messages[i].tags['id'] == targetId) {
|
if (messages[i].tags['id'] == targetId) {
|
||||||
messages[i].command = Command.clearMessage;
|
messages[i].command = Command.clearMessage;
|
||||||
|
messages[i].renderRevision++;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -127,6 +136,7 @@ class IRCMessage {
|
||||||
for (var i = 0; i < bufferedMessages.length; i++) {
|
for (var i = 0; i < bufferedMessages.length; i++) {
|
||||||
if (bufferedMessages[i].tags['id'] == targetId) {
|
if (bufferedMessages[i].tags['id'] == targetId) {
|
||||||
bufferedMessages[i].command = Command.clearMessage;
|
bufferedMessages[i].command = Command.clearMessage;
|
||||||
|
bufferedMessages[i].renderRevision++;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -591,6 +601,10 @@ class IRCMessage {
|
||||||
imageUrl: emote.url,
|
imageUrl: emote.url,
|
||||||
height: emote.height != null ? emote.height! * emoteScale : emoteSize,
|
height: emote.height != null ? emote.height! * emoteScale : emoteSize,
|
||||||
width: emote.width != null ? emote.width! * emoteScale : emoteSize,
|
width: emote.width != null ? emote.width! * emoteScale : emoteSize,
|
||||||
|
memCacheHeight: _memCacheDimension(
|
||||||
|
context,
|
||||||
|
emote.height != null ? emote.height! * emoteScale : emoteSize,
|
||||||
|
),
|
||||||
useFade: false,
|
useFade: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -748,6 +762,7 @@ class IRCMessage {
|
||||||
double? size,
|
double? size,
|
||||||
Color? backgroundColor,
|
Color? backgroundColor,
|
||||||
bool? isSvg,
|
bool? isSvg,
|
||||||
|
int? memCacheHeight,
|
||||||
}) {
|
}) {
|
||||||
if (backgroundColor != null) {
|
if (backgroundColor != null) {
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
|
|
@ -756,6 +771,7 @@ class IRCMessage {
|
||||||
imageUrl: badge.url,
|
imageUrl: badge.url,
|
||||||
height: size,
|
height: size,
|
||||||
width: size,
|
width: size,
|
||||||
|
memCacheHeight: memCacheHeight,
|
||||||
useFade: false,
|
useFade: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -766,6 +782,7 @@ class IRCMessage {
|
||||||
imageUrl: badge.url,
|
imageUrl: badge.url,
|
||||||
height: size,
|
height: size,
|
||||||
width: size,
|
width: size,
|
||||||
|
memCacheHeight: memCacheHeight,
|
||||||
useFade: false,
|
useFade: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -801,6 +818,7 @@ class IRCMessage {
|
||||||
size: size,
|
size: size,
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
isSvg: isSvg,
|
isSvg: isSvg,
|
||||||
|
memCacheHeight: _memCacheDimension(context, size),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -825,6 +843,9 @@ class IRCMessage {
|
||||||
imageUrl: emote.url,
|
imageUrl: emote.url,
|
||||||
height: height,
|
height: height,
|
||||||
width: width,
|
width: width,
|
||||||
|
// Decode at the rendered size instead of the CDN's 3x-4x intrinsic
|
||||||
|
// size. Height-only preserves the aspect ratio.
|
||||||
|
memCacheHeight: _memCacheDimension(context, height),
|
||||||
useFade: false,
|
useFade: false,
|
||||||
placeholder: (context, url) => const SizedBox(),
|
placeholder: (context, url) => const SizedBox(),
|
||||||
),
|
),
|
||||||
|
|
@ -832,6 +853,11 @@ class IRCMessage {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converts a layout dimension to a physical-pixel decode cap for the
|
||||||
|
/// in-memory image cache.
|
||||||
|
static int _memCacheDimension(BuildContext context, double logicalSize) =>
|
||||||
|
(logicalSize * MediaQuery.devicePixelRatioOf(context)).round();
|
||||||
|
|
||||||
static TextSpan _createTextSpan({
|
static TextSpan _createTextSpan({
|
||||||
required String text,
|
required String text,
|
||||||
required bool launchExternal,
|
required bool launchExternal,
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,12 @@ class _VideoChatState extends State<VideoChat>
|
||||||
|
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
_videoStore.handleAppResume();
|
_videoStore.handleAppResume();
|
||||||
|
_chatTabsStore.onAppResumed();
|
||||||
|
} else if (state == AppLifecycleState.paused) {
|
||||||
|
// No frames are produced while backgrounded, but Dart timers keep
|
||||||
|
// firing — stop the chat flush timers until resume. (Messages keep
|
||||||
|
// buffering; PiP hides chat anyway, so pausing during PiP is safe.)
|
||||||
|
_chatTabsStore.onAppPaused();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
|
import 'package:frosty/models/irc.dart';
|
||||||
|
import 'package:frosty/models/user.dart';
|
||||||
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart';
|
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart';
|
||||||
import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart';
|
import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart';
|
||||||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||||
|
|
@ -16,6 +18,27 @@ import 'package:frosty/widgets/frosty_scrollbar.dart';
|
||||||
/// ahead of fast scroll-back without holding the entire history.
|
/// ahead of fast scroll-back without holding the entire history.
|
||||||
const _chatCacheExtent = ScrollCacheExtent.pixels(500.0);
|
const _chatCacheExtent = ScrollCacheExtent.pixels(500.0);
|
||||||
|
|
||||||
|
/// Cached [ChatMessage] instances keyed by message identity. Returning the
|
||||||
|
/// identical widget instance across list rebuilds lets the framework skip the
|
||||||
|
/// row's build entirely (`Element.updateChild` short-circuits on identical
|
||||||
|
/// widgets), so a batch flush only builds the genuinely new messages instead
|
||||||
|
/// of regenerating every visible row's spans. Entries are GC'd together with
|
||||||
|
/// their messages; [IRCMessage.renderRevision] invalidates rows the moderation
|
||||||
|
/// handlers mutate in place. MobX reactivity is unaffected — each row's
|
||||||
|
/// Observer subscription lives on its element, not its widget instance.
|
||||||
|
final _messageWidgetCache = Expando<_CachedMessageWidget>();
|
||||||
|
|
||||||
|
/// Merged-mode cache, kept separate because merged rows carry per-active-tab
|
||||||
|
/// parameters that must invalidate the cached widget when they change.
|
||||||
|
final _mergedMessageWidgetCache = Expando<_CachedMessageWidget>();
|
||||||
|
|
||||||
|
class _CachedMessageWidget {
|
||||||
|
const _CachedMessageWidget(this.widget, this.revision);
|
||||||
|
|
||||||
|
final ChatMessage widget;
|
||||||
|
final int revision;
|
||||||
|
}
|
||||||
|
|
||||||
class Chat extends StatelessWidget {
|
class Chat extends StatelessWidget {
|
||||||
final ChatStore chatStore;
|
final ChatStore chatStore;
|
||||||
final EdgeInsetsGeometry? listPadding;
|
final EdgeInsetsGeometry? listPadding;
|
||||||
|
|
@ -176,6 +199,10 @@ class Chat extends StatelessWidget {
|
||||||
ScrollController scrollController,
|
ScrollController scrollController,
|
||||||
double bottomPadding,
|
double bottomPadding,
|
||||||
) {
|
) {
|
||||||
|
final messages = chatStore.renderMessages;
|
||||||
|
// Built lazily on the first keyed-child lookup of a rebuild.
|
||||||
|
Map<IRCMessage, int>? sliverIndexByMessage;
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
reverse: true,
|
reverse: true,
|
||||||
padding: (listPadding ?? EdgeInsets.zero).add(
|
padding: (listPadding ?? EdgeInsets.zero).add(
|
||||||
|
|
@ -184,23 +211,51 @@ class Chat extends StatelessWidget {
|
||||||
addAutomaticKeepAlives: false,
|
addAutomaticKeepAlives: false,
|
||||||
scrollCacheExtent: _chatCacheExtent,
|
scrollCacheExtent: _chatCacheExtent,
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
itemCount: chatStore.renderMessages.length,
|
itemCount: messages.length,
|
||||||
|
// Every appended batch shifts all sliver indices (the list is reversed,
|
||||||
|
// so new messages land at index 0). Mapping a row's key back to its new
|
||||||
|
// index here lets the framework *move* the existing element — without
|
||||||
|
// it, the key mismatch at each shifted index would deactivate and
|
||||||
|
// re-inflate every built row on every flush.
|
||||||
|
findChildIndexCallback: (key) {
|
||||||
|
final lookup = sliverIndexByMessage ??= {
|
||||||
|
for (var i = 0; i < messages.length; i++)
|
||||||
|
messages[messages.length - 1 - i]: i,
|
||||||
|
};
|
||||||
|
return lookup[(key as ObjectKey).value];
|
||||||
|
},
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final ircMessage = chatStore
|
final ircMessage = messages[messages.length - 1 - index];
|
||||||
.renderMessages[chatStore.renderMessages.length - 1 - index];
|
return _cachedChatMessage(ircMessage);
|
||||||
// Key by message identity so the list reconciles by message rather
|
|
||||||
// than by slot. This keeps each message's element (and its gesture
|
|
||||||
// recognizers) bound to the same message when new messages arrive
|
|
||||||
// mid-interaction, fixing long-press landing on the wrong message.
|
|
||||||
return ChatMessage(
|
|
||||||
key: ObjectKey(ircMessage),
|
|
||||||
ircMessage: ircMessage,
|
|
||||||
chatStore: chatStore,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the (cached) widget for [ircMessage], building it on first
|
||||||
|
/// render. Keyed by message identity so the list reconciles by message
|
||||||
|
/// rather than by slot — this keeps each message's element (and its gesture
|
||||||
|
/// recognizers) bound to the same message when new messages arrive
|
||||||
|
/// mid-interaction, fixing long-press landing on the wrong message.
|
||||||
|
Widget _cachedChatMessage(IRCMessage ircMessage) {
|
||||||
|
final cached = _messageWidgetCache[ircMessage];
|
||||||
|
if (cached != null &&
|
||||||
|
cached.revision == ircMessage.renderRevision &&
|
||||||
|
identical(cached.widget.chatStore, chatStore)) {
|
||||||
|
return cached.widget;
|
||||||
|
}
|
||||||
|
|
||||||
|
final widget = ChatMessage(
|
||||||
|
key: ObjectKey(ircMessage),
|
||||||
|
ircMessage: ircMessage,
|
||||||
|
chatStore: chatStore,
|
||||||
|
);
|
||||||
|
_messageWidgetCache[ircMessage] = _CachedMessageWidget(
|
||||||
|
widget,
|
||||||
|
ircMessage.renderRevision,
|
||||||
|
);
|
||||||
|
return widget;
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildMergedList(
|
Widget _buildMergedList(
|
||||||
ScrollController scrollController,
|
ScrollController scrollController,
|
||||||
double bottomPadding,
|
double bottomPadding,
|
||||||
|
|
@ -208,6 +263,8 @@ class Chat extends StatelessWidget {
|
||||||
final mergedMessages = chatTabsStore!.mergedMessages;
|
final mergedMessages = chatTabsStore!.mergedMessages;
|
||||||
final channelIdToUserTwitch = chatTabsStore!.mergedChannelIdToUserTwitch;
|
final channelIdToUserTwitch = chatTabsStore!.mergedChannelIdToUserTwitch;
|
||||||
final currentChannelId = chatTabsStore!.activeTab.channelId;
|
final currentChannelId = chatTabsStore!.activeTab.channelId;
|
||||||
|
// Built lazily on the first keyed-child lookup of a rebuild.
|
||||||
|
Map<IRCMessage, int>? sliverIndexByMessage;
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
reverse: true,
|
reverse: true,
|
||||||
|
|
@ -218,29 +275,73 @@ class Chat extends StatelessWidget {
|
||||||
scrollCacheExtent: _chatCacheExtent,
|
scrollCacheExtent: _chatCacheExtent,
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
itemCount: mergedMessages.length,
|
itemCount: mergedMessages.length,
|
||||||
|
// See _buildNormalList — moves existing elements across index shifts.
|
||||||
|
findChildIndexCallback: (key) {
|
||||||
|
final lookup = sliverIndexByMessage ??= {
|
||||||
|
for (var i = 0; i < mergedMessages.length; i++)
|
||||||
|
mergedMessages[mergedMessages.length - 1 - i].ircMessage: i,
|
||||||
|
};
|
||||||
|
return lookup[(key as ObjectKey).value];
|
||||||
|
},
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final merged = mergedMessages[mergedMessages.length - 1 - index];
|
final merged = mergedMessages[mergedMessages.length - 1 - index];
|
||||||
return ChatMessage(
|
return _cachedMergedChatMessage(
|
||||||
key: ObjectKey(merged.ircMessage),
|
merged,
|
||||||
ircMessage: merged.ircMessage,
|
channelIdToUserTwitch,
|
||||||
chatStore: merged.chatStore,
|
currentChannelId,
|
||||||
inputChatStore: chatStore,
|
|
||||||
onActivateSourceTab: () {
|
|
||||||
final tabs = chatTabsStore!.tabs;
|
|
||||||
final idx = tabs.indexWhere(
|
|
||||||
(t) => t.channelId == merged.chatStore.channelId,
|
|
||||||
);
|
|
||||||
if (idx != -1 && idx != chatTabsStore!.activeTabIndex) {
|
|
||||||
chatTabsStore!.setActiveTab(idx, silent: true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
overrideChannelIdToUserTwitch: channelIdToUserTwitch,
|
|
||||||
overrideCurrentChannelId: currentChannelId,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merged-mode variant of [_cachedChatMessage]. The extra identity checks
|
||||||
|
/// invalidate the cached widget when the active tab (send target) or the
|
||||||
|
/// combined channel-profile map changes.
|
||||||
|
Widget _cachedMergedChatMessage(
|
||||||
|
MergedMessage merged,
|
||||||
|
Map<String, UserTwitch> channelIdToUserTwitch,
|
||||||
|
String currentChannelId,
|
||||||
|
) {
|
||||||
|
final ircMessage = merged.ircMessage;
|
||||||
|
final cached = _mergedMessageWidgetCache[ircMessage];
|
||||||
|
final cachedWidget = cached?.widget;
|
||||||
|
if (cached != null &&
|
||||||
|
cachedWidget != null &&
|
||||||
|
cached.revision == ircMessage.renderRevision &&
|
||||||
|
identical(cachedWidget.chatStore, merged.chatStore) &&
|
||||||
|
identical(cachedWidget.inputChatStore, chatStore) &&
|
||||||
|
identical(
|
||||||
|
cachedWidget.overrideChannelIdToUserTwitch,
|
||||||
|
channelIdToUserTwitch,
|
||||||
|
) &&
|
||||||
|
cachedWidget.overrideCurrentChannelId == currentChannelId) {
|
||||||
|
return cachedWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
final widget = ChatMessage(
|
||||||
|
key: ObjectKey(ircMessage),
|
||||||
|
ircMessage: ircMessage,
|
||||||
|
chatStore: merged.chatStore,
|
||||||
|
inputChatStore: chatStore,
|
||||||
|
onActivateSourceTab: () {
|
||||||
|
final tabs = chatTabsStore!.tabs;
|
||||||
|
final idx = tabs.indexWhere(
|
||||||
|
(t) => t.channelId == merged.chatStore.channelId,
|
||||||
|
);
|
||||||
|
if (idx != -1 && idx != chatTabsStore!.activeTabIndex) {
|
||||||
|
chatTabsStore!.setActiveTab(idx, silent: true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
overrideChannelIdToUserTwitch: channelIdToUserTwitch,
|
||||||
|
overrideCurrentChannelId: currentChannelId,
|
||||||
|
);
|
||||||
|
_mergedMessageWidgetCache[ircMessage] = _CachedMessageWidget(
|
||||||
|
widget,
|
||||||
|
ircMessage.renderRevision,
|
||||||
|
);
|
||||||
|
return widget;
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildResumeScrollButton() {
|
Widget _buildResumeScrollButton() {
|
||||||
return Builder(
|
return Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,12 @@ abstract class ChatStoreBase with Store {
|
||||||
/// The periodic timer used for batching chat message re-renders.
|
/// The periodic timer used for batching chat message re-renders.
|
||||||
Timer? _messageBufferTimer;
|
Timer? _messageBufferTimer;
|
||||||
|
|
||||||
|
/// Whether the app is backgrounded. Gates the message flush timer — no
|
||||||
|
/// frames are produced while paused, so the 5x/s wakeups would be pure
|
||||||
|
/// battery cost. Messages keep parsing into [messageBuffer] and are flushed
|
||||||
|
/// on resume (with the overflow drain in [_handleIRCData] bounding growth).
|
||||||
|
var _isAppPaused = false;
|
||||||
|
|
||||||
/// The list of chat messages to add once autoscroll is resumed.
|
/// The list of chat messages to add once autoscroll is resumed.
|
||||||
/// This is used as an optimization to prevent the list from being updated/shifted while the user is scrolling.
|
/// This is used as an optimization to prevent the list from being updated/shifted while the user is scrolling.
|
||||||
final messageBuffer = ObservableList<IRCMessage>();
|
final messageBuffer = ObservableList<IRCMessage>();
|
||||||
|
|
@ -817,8 +823,10 @@ abstract class ChatStoreBase with Store {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_autoScroll) {
|
if (!_autoScroll || _isAppPaused) {
|
||||||
// While autoscroll is disabled, occasionally move messages from the buffer to the messages to prevent a memory leak.
|
// While the flush is held (scrolled up, or app backgrounded),
|
||||||
|
// occasionally move messages from the buffer to the messages to
|
||||||
|
// prevent unbounded buffer growth.
|
||||||
if (messageBuffer.length >= _messagesToRemove) {
|
if (messageBuffer.length >= _messagesToRemove) {
|
||||||
_messages.addAll(messageBuffer);
|
_messages.addAll(messageBuffer);
|
||||||
messageBuffer.clear();
|
messageBuffer.clear();
|
||||||
|
|
@ -841,13 +849,7 @@ abstract class ChatStoreBase with Store {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Activate the message buffer.
|
// Activate the message buffer.
|
||||||
// Cancel any existing timer before creating a new one to prevent duplicates on reconnect.
|
_startMessageBufferTimer();
|
||||||
_messageBufferTimer?.cancel();
|
|
||||||
// Create a timer that will add messages from the buffer every 200 milliseconds.
|
|
||||||
_messageBufferTimer = Timer.periodic(
|
|
||||||
const Duration(milliseconds: 200),
|
|
||||||
(timer) => addMessages(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set up 7TV real-time listener (assets already fetched in connectToChat)
|
// Set up 7TV real-time listener (assets already fetched in connectToChat)
|
||||||
if (settings.show7TVEmotes) {
|
if (settings.show7TVEmotes) {
|
||||||
|
|
@ -1251,6 +1253,38 @@ abstract class ChatStoreBase with Store {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// (Re)starts the periodic buffer flush. Cancels any existing timer first
|
||||||
|
/// to prevent duplicates on reconnect; no-ops while the app is backgrounded
|
||||||
|
/// ([onAppResumed] restarts it).
|
||||||
|
void _startMessageBufferTimer() {
|
||||||
|
_messageBufferTimer?.cancel();
|
||||||
|
_messageBufferTimer = null;
|
||||||
|
if (_isAppPaused) return;
|
||||||
|
// Create a timer that will add messages from the buffer every 200 milliseconds.
|
||||||
|
_messageBufferTimer = Timer.periodic(
|
||||||
|
const Duration(milliseconds: 200),
|
||||||
|
(timer) => addMessages(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops the flush timer while the app is backgrounded.
|
||||||
|
void onAppPaused() {
|
||||||
|
_isAppPaused = true;
|
||||||
|
_messageBufferTimer?.cancel();
|
||||||
|
_messageBufferTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restarts the flush timer after [onAppPaused] and flushes the backlog
|
||||||
|
/// immediately so chat is current on the first visible frame.
|
||||||
|
void onAppResumed() {
|
||||||
|
if (!_isAppPaused) return;
|
||||||
|
_isAppPaused = false;
|
||||||
|
if (_hasConnected) {
|
||||||
|
_startMessageBufferTimer();
|
||||||
|
addMessages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
void addMessages() {
|
void addMessages() {
|
||||||
_flushDueDelayedCallbacks();
|
_flushDueDelayedCallbacks();
|
||||||
|
|
|
||||||
|
|
@ -407,6 +407,32 @@ abstract class ChatTabsStoreBase with Store {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forwards app-pause to every activated tab and stops the merged render
|
||||||
|
/// timer — no frames are produced while the app is backgrounded, so the
|
||||||
|
/// periodic flush/recompute work would be pure battery cost.
|
||||||
|
void onAppPaused() {
|
||||||
|
_mergedRenderTimer?.cancel();
|
||||||
|
_mergedRenderTimer = null;
|
||||||
|
for (final tab in _tabs) {
|
||||||
|
tab.chatStore?.onAppPaused();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resumes tab flush timers and (if merged mode is active) the merged
|
||||||
|
/// render timer after [onAppPaused].
|
||||||
|
void onAppResumed() {
|
||||||
|
for (final tab in _tabs) {
|
||||||
|
tab.chatStore?.onAppResumed();
|
||||||
|
}
|
||||||
|
if (mergedMode && _mergedRenderTimer == null) {
|
||||||
|
_refreshMergedMessages();
|
||||||
|
_mergedRenderTimer = Timer.periodic(
|
||||||
|
const Duration(milliseconds: 200),
|
||||||
|
(_) => _refreshMergedMessages(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-enables auto-scroll and jumps to the latest message in merged view.
|
/// Re-enables auto-scroll and jumps to the latest message in merged view.
|
||||||
@action
|
@action
|
||||||
void resumeMergedScroll() {
|
void resumeMergedScroll() {
|
||||||
|
|
|
||||||
|
|
@ -112,17 +112,26 @@ class ChatTabs extends StatelessWidget {
|
||||||
)
|
)
|
||||||
: IndexedStack(
|
: IndexedStack(
|
||||||
index: activeIndex,
|
index: activeIndex,
|
||||||
children: tabs.map((tabInfo) {
|
children: [
|
||||||
if (tabInfo.chatStore == null) {
|
for (var i = 0; i < tabs.length; i++)
|
||||||
return const SizedBox.shrink();
|
if (tabs[i].chatStore == null)
|
||||||
}
|
const SizedBox.shrink()
|
||||||
return Chat(
|
else
|
||||||
key: ValueKey(tabInfo.channelId),
|
// IndexedStack children stay mounted (and keep
|
||||||
chatStore: tabInfo.chatStore!,
|
// animating) when hidden. Disabling TickerMode
|
||||||
listPadding: adjustedPadding,
|
// pauses inactive tabs' animated emotes — the
|
||||||
onAddChat: () => _handleAddChat(context),
|
// Image widget stops its frame-decode loop while
|
||||||
);
|
// the ticker mode is off and resumes on switch.
|
||||||
}).toList(),
|
TickerMode(
|
||||||
|
key: ValueKey(tabs[i].channelId),
|
||||||
|
enabled: i == activeIndex,
|
||||||
|
child: Chat(
|
||||||
|
chatStore: tabs[i].chatStore!,
|
||||||
|
listPadding: adjustedPadding,
|
||||||
|
onAddChat: () => _handleAddChat(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (showTabBar)
|
if (showTabBar)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,12 @@ double _contrastRatio(Color a, Color b) {
|
||||||
return (hi + 0.05) / (lo + 0.05);
|
return (hi + 0.05) / (lo + 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Memoized results for [adjustChatNameColor]. Chat renders the same handful
|
||||||
|
/// of Twitch palette colors thousands of times per session, and every call
|
||||||
|
/// costs luminance math (plus a 16-step search for low-contrast colors) — the
|
||||||
|
/// answer only depends on this key, so compute it once per combination.
|
||||||
|
final _adjustedNameColorCache = <(int, int, double), Color>{};
|
||||||
|
|
||||||
/// Adjusts [color] just enough to reach [targetContrast] on the app's background.
|
/// Adjusts [color] just enough to reach [targetContrast] on the app's background.
|
||||||
/// Defaults to Theme.of(context).scaffoldBackgroundColor; if transparent, falls
|
/// Defaults to Theme.of(context).scaffoldBackgroundColor; if transparent, falls
|
||||||
/// back to theme.colorScheme.surface.
|
/// back to theme.colorScheme.surface.
|
||||||
|
|
@ -44,6 +50,18 @@ Color adjustChatNameColor(
|
||||||
bg = theme.colorScheme.surface;
|
bg = theme.colorScheme.surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final cacheKey = (color.toARGB32(), bg.toARGB32(), targetContrast);
|
||||||
|
final cached = _adjustedNameColorCache[cacheKey];
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
final adjusted = _adjustNameColorUncached(color, bg, targetContrast);
|
||||||
|
// Custom hex name colors are unbounded in theory; keep the cache small.
|
||||||
|
if (_adjustedNameColorCache.length >= 1024) _adjustedNameColorCache.clear();
|
||||||
|
_adjustedNameColorCache[cacheKey] = adjusted;
|
||||||
|
return adjusted;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _adjustNameColorUncached(Color color, Color bg, double targetContrast) {
|
||||||
if (_contrastRatio(color, bg) >= targetContrast) return color;
|
if (_contrastRatio(color, bg) >= targetContrast) return color;
|
||||||
|
|
||||||
final hsl0 = HSLColor.fromColor(color);
|
final hsl0 = HSLColor.fromColor(color);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,16 @@ class FrostyCachedNetworkImage extends StatelessWidget {
|
||||||
final String? cacheKey;
|
final String? cacheKey;
|
||||||
final double? width;
|
final double? width;
|
||||||
final double? height;
|
final double? height;
|
||||||
|
|
||||||
|
/// Decode size caps (physical pixels) for the in-memory image. Without
|
||||||
|
/// these, images decode and stay resident at their intrinsic size — emote
|
||||||
|
/// CDNs serve 3x-4x assets (~112-128 px) that render in ~28 lp boxes, so
|
||||||
|
/// capping the decode cuts memory and raster cost several-fold. Applies to
|
||||||
|
/// animated codecs too (all frames decode at the target size; animation is
|
||||||
|
/// preserved). Providing only one axis keeps the aspect ratio.
|
||||||
|
final int? memCacheWidth;
|
||||||
|
final int? memCacheHeight;
|
||||||
|
|
||||||
final Color? color;
|
final Color? color;
|
||||||
final BlendMode? colorBlendMode;
|
final BlendMode? colorBlendMode;
|
||||||
final Widget Function(BuildContext, String)? placeholder;
|
final Widget Function(BuildContext, String)? placeholder;
|
||||||
|
|
@ -21,6 +31,8 @@ class FrostyCachedNetworkImage extends StatelessWidget {
|
||||||
this.cacheKey,
|
this.cacheKey,
|
||||||
this.width,
|
this.width,
|
||||||
this.height,
|
this.height,
|
||||||
|
this.memCacheWidth,
|
||||||
|
this.memCacheHeight,
|
||||||
this.color,
|
this.color,
|
||||||
this.colorBlendMode,
|
this.colorBlendMode,
|
||||||
this.placeholder,
|
this.placeholder,
|
||||||
|
|
@ -36,6 +48,8 @@ class FrostyCachedNetworkImage extends StatelessWidget {
|
||||||
cacheKey: cacheKey,
|
cacheKey: cacheKey,
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
|
memCacheWidth: memCacheWidth,
|
||||||
|
memCacheHeight: memCacheHeight,
|
||||||
color: color,
|
color: color,
|
||||||
colorBlendMode: colorBlendMode,
|
colorBlendMode: colorBlendMode,
|
||||||
placeholder: placeholder,
|
placeholder: placeholder,
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,35 @@ void main() {
|
||||||
expect(affectedMessage.tags['ban-duration'], '300');
|
expect(affectedMessage.tags['ban-duration'], '300');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('bumps renderRevision only on affected messages', () {
|
||||||
|
final clearChatMsg = IRCMessage(
|
||||||
|
raw: '',
|
||||||
|
command: Command.clearChat,
|
||||||
|
tags: {'ban-duration': '600'},
|
||||||
|
message: 'testuser',
|
||||||
|
);
|
||||||
|
bufferedMessages = [
|
||||||
|
IRCMessage.fromString(basicPrivmsg), // from testuser
|
||||||
|
];
|
||||||
|
|
||||||
|
IRCMessage.clearChat(
|
||||||
|
messages: messages,
|
||||||
|
bufferedMessages: bufferedMessages,
|
||||||
|
ircMessage: clearChatMsg,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mutated-in-place messages must signal render caches to invalidate.
|
||||||
|
expect(
|
||||||
|
messages.where((m) => m.user == 'testuser').first.renderRevision,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(bufferedMessages.first.renderRevision, 1);
|
||||||
|
expect(
|
||||||
|
messages.where((m) => m.user == 'moduser').first.renderRevision,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('clears entire chat when no target user', () {
|
test('clears entire chat when no target user', () {
|
||||||
final clearAllMsg = IRCMessage(
|
final clearAllMsg = IRCMessage(
|
||||||
raw: '',
|
raw: '',
|
||||||
|
|
@ -408,6 +437,15 @@ void main() {
|
||||||
messages.firstWhere((m) => m.tags['id'] == 'msg-2').command,
|
messages.firstWhere((m) => m.tags['id'] == 'msg-2').command,
|
||||||
Command.clearMessage,
|
Command.clearMessage,
|
||||||
);
|
);
|
||||||
|
// Mutated-in-place message must signal render caches to invalidate.
|
||||||
|
expect(
|
||||||
|
messages.firstWhere((m) => m.tags['id'] == 'msg-2').renderRevision,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
messages.firstWhere((m) => m.tags['id'] == 'msg-1').renderRevision,
|
||||||
|
0,
|
||||||
|
);
|
||||||
// Other messages unchanged
|
// Other messages unchanged
|
||||||
expect(
|
expect(
|
||||||
messages.firstWhere((m) => m.tags['id'] == 'msg-1').command,
|
messages.firstWhere((m) => m.tags['id'] == 'msg-1').command,
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,45 @@ void main() {
|
||||||
expect(adjustedColor, isNot(const Color(0xFF00FF00)));
|
expect(adjustedColor, isNot(const Color(0xFF00FF00)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('memoized results are stable and keyed by background',
|
||||||
|
(tester) async {
|
||||||
|
late Color first;
|
||||||
|
late Color second;
|
||||||
|
late Color onLightBackground;
|
||||||
|
const darkBackground = Color(0xFF121212);
|
||||||
|
const lowContrastColor = Color(0xFF1F1F1F); // Near-black on dark bg
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildTestWidget(
|
||||||
|
scaffoldBackground: darkBackground,
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
// Repeated calls with identical inputs must return the same
|
||||||
|
// adjusted color (second call is served from the memo cache).
|
||||||
|
first = adjustChatNameColor(context, lowContrastColor);
|
||||||
|
second = adjustChatNameColor(context, lowContrastColor);
|
||||||
|
// A different background must not be served the dark-background
|
||||||
|
// cache entry: near-black already passes on white, so it should
|
||||||
|
// come back unchanged.
|
||||||
|
onLightBackground = adjustChatNameColor(
|
||||||
|
context,
|
||||||
|
lowContrastColor,
|
||||||
|
background: const Color(0xFFFFFFFF),
|
||||||
|
);
|
||||||
|
return const SizedBox();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(second, first);
|
||||||
|
expect(
|
||||||
|
first.computeLuminance(),
|
||||||
|
greaterThan(lowContrastColor.computeLuminance()),
|
||||||
|
);
|
||||||
|
expect(onLightBackground, lowContrastColor);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('handles transparent scaffold background', (tester) async {
|
testWidgets('handles transparent scaffold background', (tester) async {
|
||||||
late Color adjustedColor;
|
late Color adjustedColor;
|
||||||
const darkColor = Color(0xFF000000);
|
const darkColor = Color(0xFF000000);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue