From 1a11b9ac606056f94a9114de229549298724616d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 10 May 2026 17:01:50 +0200 Subject: [PATCH] refactored internal documentation and simplified comments across chat BLoCs, file viewer, and navigation components --- lib/app.dart | 59 +++++-------------- lib/notification/notification_controller.dart | 15 ++--- .../app/modules/chat/bloc/chat_bloc.dart | 59 ++++++------------- .../chat_list/bloc/chat_list_bloc.dart | 22 ++----- .../widgets/chat_message_options_dialog.dart | 11 +--- .../talk/widgets/highlighted_linkify.dart | 19 ++---- lib/widget/file_viewer.dart | 59 +++++-------------- lib/widget/user_avatar.dart | 9 +-- 8 files changed, 68 insertions(+), 185 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 7edab89..eb3b3eb 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -41,9 +41,6 @@ class _AppState extends State with WidgetsBindingObserver { StreamSubscription? _onMessageSub; StreamSubscription? _onMessageOpenedAppSub; StreamSubscription? _fcmTokenRefreshSub; - // Tracked via the bottom-nav controller's listener so it always reflects the - // user's actual position, even between rapid setting emits where the - // controller hasn't caught up to a scheduled jump yet. int _knownTotalTabs = 1; bool _userOnLastTab = false; @@ -84,12 +81,9 @@ class _AppState extends State with WidgetsBindingObserver { Future _handlePendingWidgetNavigation() async { final pending = await WidgetNavigation.consumePendingTimetableTap(); if (!pending || !mounted) return; - // Routes pushed with `withNavBar: false` (chat views, file viewers, …) - // sit on the root navigator above the bottom-nav, so a bare jumpToTab - // would swap the tab behind them and leave the user staring at the - // previous screen. Reset to the tab root first — but stop at any open - // popup so a confirmation dialog or bottom sheet that the user hasn't - // dismissed yet doesn't get silently torn down. + // `withNavBar: false` routes sit on the root navigator above the + // bottom-nav; pop them so jumpToTab is actually visible. Stop at + // popups so open dialogs/sheets stay alive. final navigator = Navigator.of(context); if (navigator.canPop()) { navigator.popUntil((route) => route.isFirst || route is PopupRoute); @@ -101,10 +95,8 @@ class _AppState extends State with WidgetsBindingObserver { if (!mounted) return; final share = ShareIntentListener.pending.value; if (share == null) return; - // A second share arriving while a previous share-flow page is still on - // the stack would otherwise leave the old page sitting on top with stale - // (already-cleared) file paths. Reset to the tab root before pushing — - // but stop at any open popup so dialogs/bottom-sheets remain intact. + // A second share would otherwise leave the previous share-flow page + // on top with stale (already-cleared) file paths. final navigator = Navigator.of(context); if (navigator.canPop()) { navigator.popUntil((route) => route.isFirst || route is PopupRoute); @@ -123,15 +115,11 @@ class _AppState extends State with WidgetsBindingObserver { if (!mounted) return; context.read().refresh(); context.read().refresh(); - // App is freshly mounted on every login (BlocConsumer in main.dart - // swaps it in for Login), so this also covers the post-logout case - // where the bloc was reset to an empty state and needs a fresh fetch. + // Re-mounts on every login, so this also covers post-logout state reset. final timetable = context.read(); timetable.refresh(); - // Push the freshest timetable state into the home-screen widget any - // time the BLoC reports new data — without waiting for the periodic - // background refresh. This is the "user just opened the app" path: - // the widget gets the same data the user is looking at on screen. + // Mirror BLoC updates into the home-screen widget without waiting + // for the periodic background refresh. final settingsCubit = context.read(); _timetableWidgetSync?.cancel(); _timetableWidgetSync = timetable.stream.listen((state) { @@ -145,8 +133,7 @@ class _AppState extends State with WidgetsBindingObserver { ); } }); - // Also publish the current state once, in case data is already loaded - // from hydrated storage before the listener attaches. + // Initial publish in case hydrated storage already has data. final initialData = timetable.state.data; if (initialData is TimetableState) { unawaited( @@ -222,17 +209,9 @@ class _AppState extends State with WidgetsBindingObserver { final totalTabs = bottomBarModules.length + 1; final currentIndex = Main.bottomNavigator.index; - // The bottom-bar layout is identified by the ordered list of module - // names plus the trailing 'more' slot. Whenever this layout changes - // — slot count, reordering, or hiding a module — we recreate the - // entire PersistentTabView via the [layoutKey] below. The package - // caches per-tab navigator state by index in `_navigatorKeys`, and - // its internal `alignLength` only ever appends or trims at the end. - // So when the module sitting at e.g. index 3 changes, the navigator - // at that index still serves the old screen's route stack and the - // user sees stale content. Re-mounting clears those stacks; the - // trade-off (losing in-tab pushed routes on a settings change) is - // acceptable since the user explicitly re-shaped the bar. + // PersistentTabView caches per-tab navigators by index and only + // appends/trims at the end, so reordering/hiding leaves stale + // route stacks under the wrong tabs. Re-key on layout to remount. final layoutKey = ValueKey( '${bottomBarModules.map((m) => m.module.name).join('|')}|more', ); @@ -244,12 +223,8 @@ class _AppState extends State with WidgetsBindingObserver { } else if (currentIndex >= totalTabs) { targetIndex = totalTabs - 1; } - // Re-mounting PTV with a new key constructs fresh internals from - // its controller's current index. If the controller still points - // past the new tab list, Style6BottomNavBar (and others) crash on - // out-of-range access during initState. Replace the controller - // atomically with one initialised at the safe target index so the - // new PTV mounts cleanly. + // Replace the controller atomically: a stale index past the new + // tab list crashes Style6BottomNavBar's initState. if (targetIndex != currentIndex) { Main.bottomNavigator.removeListener(_onTabControllerChanged); Main.bottomNavigator = PersistentTabController( @@ -285,10 +260,8 @@ class _AppState extends State with WidgetsBindingObserver { ), ], navBarBuilder: (config) => Style6BottomNavBar( - // Style6BottomNavBar builds its internal animation controller list - // in initState and never grows it on didUpdateWidget. Keying by the - // item count forces a fresh State whenever the slot count changes, - // which avoids a RangeError when more tabs slide in. + // Animation controllers are built once in initState and never + // grown — re-key on item count to avoid RangeError on growth. key: ValueKey(config.items.length), navBarConfig: config, navBarDecoration: NavBarDecoration( diff --git a/lib/notification/notification_controller.dart b/lib/notification/notification_controller.dart index f02e3af..fafe77b 100644 --- a/lib/notification/notification_controller.dart +++ b/lib/notification/notification_controller.dart @@ -10,12 +10,10 @@ import '../widget/debug/json_viewer.dart'; import '../widget/info_dialog.dart'; import 'notification_tasks.dart'; -// `vm:entry-point` on the class so AOT tree-shaking doesn't drop it: the -// FCM background handler runs in a fresh isolate that looks up the class -// by name from native code. +// `vm:entry-point` keeps this alive through AOT tree-shaking — the FCM +// background isolate looks the class up by name from native code. @pragma('vm:entry-point') class NotificationController { - // Notification display is handled by the Firebase SDK using server-generated payloads. @pragma('vm:entry-point') static Future onBackgroundMessageHandler(RemoteMessage message) async { NotificationTasks.updateBadgeCount(message); @@ -27,10 +25,8 @@ class NotificationController { ) async { final pushToken = _extractChatToken(message); final chatBloc = context.read(); - // hasOpenChat (not currentToken) is the source of truth here: - // currentToken sticks around after leaveChat so that didPopNext can - // re-claim a stacked chat. Using it would suppress notifications for - // the last-opened chat even after the user has navigated away. + // hasOpenChat, not currentToken: currentToken sticks around after + // leaveChat so didPopNext can re-claim a stacked chat. final activeToken = chatBloc.state.data?.currentToken ?? ''; final chatIsOpen = chatBloc.hasOpenChat && @@ -41,8 +37,7 @@ class NotificationController { NotificationTasks.updateBadgeCount(message); if (chatIsOpen) { - // Long-poll already fetches the message and moves the marker; just - // dismiss any tray entry that slipped through foreground rendering. + // Long-poll handles the message; just dismiss any stray tray entry. unawaited(NotificationTasks.clearNotificationsForChat(pushToken)); return; } diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index 05a97b8..8ccfb9e 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -28,18 +28,11 @@ class ChatBloc int _lastKnownMessageId = 0; bool _appResumed = true; - /// Distinguishes "the bloc tracks a chat the user has open" from "the - /// bloc remembers the last opened chat". App-resume only refreshes when - /// true — otherwise we'd silently mark a long-since-left chat as read - /// on the server. Can't reuse `currentToken` for this signal because - /// clearing it on leaveChat raced with setToken-from-didPopNext when - /// popping a stacked chat. + /// True only while a ChatView is mounted. Can't reuse `currentToken` — + /// clearing it on leaveChat races with setToken from didPopNext when + /// popping a stacked chat, causing spurious server read-markers on resume. bool _chatViewActive = false; - /// True only while a ChatView is actually mounted and tracking its room. - /// Read by the notification controller to decide whether an incoming push - /// belongs to the chat the user is currently looking at — `currentToken` - /// alone would yield false-positives for the last opened chat. bool get hasOpenChat => _chatViewActive; DateTime _lastTokenSet = DateTime.fromMillisecondsSinceEpoch(0); @@ -100,18 +93,15 @@ class ChatBloc add(Emit((s) => s.copyWith(referenceMessageId: messageId))); } - /// Token-aware: only acts when the bloc still points at [fromToken]. - /// When popping a stacked chat (notification opened B over A), A's - /// didPopNext has already run setToken(A) by the time B's dispose - /// fires — at that point currentToken is A and we must leave it alone. + /// No-op when the bloc has already moved on to a different token: when + /// popping a stacked chat (B over A), A's didPopNext runs setToken(A) + /// before B's dispose fires. void leaveChat(String fromToken) { if ((innerState?.currentToken ?? '') != fromToken) return; _chatViewActive = false; _stopLongPoll(); } - /// Fire-and-forget server-side read-marker. Exposed so view-side - /// callers (long-press menu, ChatView dispose) hit the same path. Future sendServerReadMarker(String token, int messageId) async { try { await SetReadMarker( @@ -137,10 +127,9 @@ class ChatBloc if (token.isNotEmpty && _chatViewActive) refresh(); } - /// Defer _loadChat by one microtask so the Bloc worker processes the - /// preceding Emit/RefetchStarted before any cache/network callback - /// fires — otherwise a quick cache hit can run with the previous - /// token in state, fail stillCurrent(), and never emit a DataGathered. + /// Microtask hop so the Bloc worker drains the preceding Emit before + /// any cache callback fires — a quick cache hit otherwise runs with + /// the previous token in state and fails stillCurrent(). void _scheduleLoad(String token) { Future.microtask(() { if (isClosed) return; @@ -164,20 +153,15 @@ class ChatBloc token: token, onCacheData: (data) { if (!stillCurrent()) return; - // Only paint cache when the state is empty — restoring a stale - // disk snapshot over already-merged long-poll data would visibly - // drop those messages until the network call resolves. + // Skip cache paint over already-merged long-poll data — would + // visibly drop those messages until the network call resolves. if (innerState?.chatResponse != null) return; add(Emit((s) => s.copyWith(chatResponse: data))); }, onNetworkData: (data) { - // Server-side mark runs unconditionally with the freshly-fetched - // maxId — skipping it on stillCurrent==false would leave the - // server cursor wherever a quick navigation away left it. The - // cache check below avoids a redundant POST when the long-poll - // (setReadMarker=on) or a previous open already moved the cursor - // to this exact id; without it every chat-open did one extra - // round-trip even when there was nothing to mark. + // Mark runs even if no longer current — otherwise a quick + // navigation away leaves the server cursor stale. Cache check + // skips the POST when the cursor is already at maxId. final maxId = _maxMessageId(data); if (maxId > 0) { final cached = _chatListBloc?.lastReadMessageFor(token); @@ -210,10 +194,6 @@ class ChatBloc } } - // --------------------------------------------------------------------------- - // Long-poll loop - // --------------------------------------------------------------------------- - void _startLongPoll(String token) { if (!_appResumed) return; if (_pollingToken == token) return; @@ -253,8 +233,7 @@ class ChatBloc _applyChatResponse(response); final maxId = _maxMessageId(response); if (maxId > _lastKnownMessageId) _lastKnownMessageId = maxId; - // Long-poll's setReadMarker=on already moved the server cursor; - // mirror locally. + // Long-poll's setReadMarker=on moved the server cursor; mirror locally. final preview = _pickDisplayMessage(response); if (preview != null) { _chatListBloc?.applyIncomingMessage(token, preview); @@ -270,10 +249,7 @@ class ChatBloc } } - /// Merges [incoming] into the existing chatResponse and emits as a - /// fresh fetch. Dedups by id (newer wins, so server edits/deletes - /// propagate). Shared by initial-load and long-poll so neither wipes - /// messages the other already committed. + /// Dedups by id with newer-wins so server edits/deletes propagate. void _applyChatResponse(GetChatResponse incoming) { final current = innerState?.chatResponse; if (current == null) { @@ -301,8 +277,7 @@ class ChatBloc return max; } - /// Highest-id message worth showing as the room preview — comments - /// and voice messages, matching what the server picks for `lastMessage`. + /// Mirrors the server's own `lastMessage` selection (comments + voice only). GetChatResponseObject? _pickDisplayMessage(GetChatResponse response) { GetChatResponseObject? best; for (final m in response.data) { diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart index fbbaa8b..31e9889 100644 --- a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -32,9 +32,7 @@ class ChatListBloc return super.close(); } - /// Sets (or clears) the recurring background refresh. Silent so the - /// loading bar doesn't blink several times a minute; pull-to-refresh - /// and tab-activation refreshes are non-silent for explicit feedback. + /// Silent refresh — explicit pull-to-refresh and tab-activation are non-silent. void setAutoRefreshInterval(Duration? interval) { if (interval == _autoRefreshInterval) return; _autoRefreshInterval = interval; @@ -107,10 +105,6 @@ class ChatListBloc await refresh(); } - /// Returns the cached `lastReadMessage` for the room with [token], or - /// `null` if the room is not (yet) known. Used by [ChatBloc] to skip - /// redundant server read-marker calls when the local cache already - /// reflects the same position. int? lastReadMessageFor(String token) { final rooms = innerState?.rooms; if (rooms == null) return null; @@ -120,9 +114,7 @@ class ChatListBloc return null; } - /// Optimistically clears the unread counter for [token] so the tile - /// reacts before a refresh roundtrip lands. Server-side mark-as-read - /// is the caller's job (see [ChatBloc.sendServerReadMarker]). + /// Optimistic — server-side mark-as-read is the caller's job. void markRoomAsRead(String token, int lastMessageId) { _mutateRoom(token, (r) { if (r.unreadMessages == 0 && r.lastReadMessage >= lastMessageId) { @@ -136,10 +128,7 @@ class ChatListBloc }); } - /// Pushes a freshly-received message into the matching room tile so the - /// list shows the right preview text + activity timestamp before the - /// next full refresh lands. Also clears unread because the long-poll - /// only feeds this in for an actively-open chat. + /// Clears unread too — long-poll only feeds this in for an actively-open chat. void applyIncomingMessage(String token, GetChatResponseObject message) { _mutateRoom(token, (r) { final wasRead = @@ -156,10 +145,7 @@ class ChatListBloc }); } - /// Mutates the room with [token] in-place via [mutator] (returning - /// true if anything changed) and re-emits if so. Builds a fresh - /// [GetRoomResponse] so equality-by-identity in the bloc state - /// recognises the change and rebuilds. + /// Re-wraps in a fresh [GetRoomResponse] so identity-based equality picks it up. void _mutateRoom( String token, bool Function(GetRoomResponseObject room) mutator, diff --git a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart index 99a9f43..5ac03b9 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -30,9 +30,6 @@ RichObjectString? _attachedFile(GetChatResponseObject bubbleData) { return file; } -/// Long-press / double-tap options dialog for a single chat message bubble. -/// The hosting [ChatBubble] keeps responsibility for rendering the bubble; -/// this file owns the modal interactions (react, reply, copy, delete, ...). void showChatMessageOptionsDialog( BuildContext context, { required GetRoomResponseObject chatData, @@ -183,11 +180,9 @@ void _openOrCreateDirectChat( } void switchToChat(GetRoomResponseObject room) { - // Pop the current ChatView before swapping the global ChatBloc token — - // otherwise the previous group chat stays mounted in the back-stack and - // would render empty after a back-swipe (currentToken no longer matches). - // Stops at any open popup so a confirmation dialog still in flight does - // not get silently dismissed. + // Pop the previous ChatView first — otherwise it stays in the + // back-stack with a now-mismatched currentToken and renders empty + // on back-swipe. Stop at popups so an open dialog stays alive. Navigator.of( context, ).popUntil((route) => route.isFirst || route is PopupRoute); diff --git a/lib/view/pages/talk/widgets/highlighted_linkify.dart b/lib/view/pages/talk/widgets/highlighted_linkify.dart index f128b9c..e335f91 100644 --- a/lib/view/pages/talk/widgets/highlighted_linkify.dart +++ b/lib/view/pages/talk/widgets/highlighted_linkify.dart @@ -78,9 +78,8 @@ class HighlightedLinkify extends StatefulWidget { } class _HighlightedLinkifyState extends State { - // Cached per link text so character-by-character search rebuilds don't - // churn through allocate/dispose on every keystroke. Stale entries are - // pruned at the end of each build via [_seenLinkKeys]. + // Cached per link text — search rebuilds keystroke-by-keystroke + // would otherwise churn allocate/dispose. Pruned via [_seenLinkKeys]. final Map _recognizers = {}; final Set _seenLinkKeys = {}; @@ -97,8 +96,7 @@ class _HighlightedLinkifyState extends State { final key = el.text; final existing = _recognizers[key]; if (existing != null) { - // Refresh onTap so a new widget.onOpen callback (from a parent - // rebuild) picks up the latest closure. + // Refresh onTap so a parent rebuild's new closure is picked up. existing.onTap = () => widget.onOpen?.call(el); return existing; } @@ -124,14 +122,9 @@ class _HighlightedLinkifyState extends State { final defaultStyle = widget.style ?? Theme.of(context).textTheme.bodyMedium ?? DefaultTextStyle.of(context).style; - // Start from the surrounding text style so links inherit font family, - // size, weight, etc., then layer the link-specific color and underline - // on top. (Going the other way around — link style as base — used to - // work because TextStyle.copyWith treats `null` as "leave unchanged", - // so the explicit `color: null, decoration: null` were silently - // ignored and the merge pulled defaultStyle's color/decoration over - // the blue + underline. Result: links rendered in body-text color - // with no underline.) + // Default first, link style on top — reversing the merge silently + // drops link color/underline because TextStyle.merge treats explicit + // nulls in the overlay as "leave unchanged". final linkStyle = defaultStyle.merge( widget.linkStyle ?? const TextStyle( diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index f9fe9c1..8179e21 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -26,9 +26,8 @@ class FileViewer extends StatefulWidget { final String path; final bool openExternal; - /// When set, enables the in-app actions "An Chat senden" and "In Dateien - /// speichern" — these need a server-side reference, not the local cache - /// path. Aufrufer reichen die Referenz durch (siehe AppRoutes.openFileViewer). + /// Enables in-app "An Chat senden" / "In Dateien speichern" — these + /// need a server-side reference instead of the local cache path. final RemoteFileRef? remoteFile; const FileViewer({ @@ -56,8 +55,6 @@ const Set _imageExtensions = { 'wbmp', }; -/// Video container formats whose playback the platform decoders (ExoPlayer -/// on Android, AVPlayer on iOS) handle out of the box. const Set _videoExtensions = { 'mp4', 'm4v', @@ -67,9 +64,8 @@ const Set _videoExtensions = { '3gp', }; -/// Audio formats playable through the same `video_player` pipeline. Some -/// (ogg/opus/flac) work on Android only — iOS will surface an init error -/// which we catch and surface as a friendly fallback. +/// ogg/opus/flac are Android-only; iOS init errors fall through to the +/// "format not supported" message. const Set _audioExtensions = { 'mp3', 'm4a', @@ -81,9 +77,7 @@ const Set _audioExtensions = { 'opus', }; -/// Extensions whose contents we render directly as plain text. Anything -/// outside this list still gets a content-based fallback check (see -/// [_looksLikeText]) so generic "what is this file" cases work too. +/// Unknown extensions still get a content sniff via [_looksLikeText]. const Set _textExtensions = { 'txt', 'md', 'markdown', 'rst', 'log', 'json', 'json5', 'xml', 'yaml', 'yml', 'toml', @@ -104,10 +98,7 @@ const Set _textExtensions = { 'srt', 'vtt', }; -/// Reads up to 8 KB and decides whether the bytes look like UTF-8 text. -/// NUL bytes and non-decodable sequences disqualify the file. Used as a -/// fallback for unknown extensions so plain text files without a familiar -/// suffix still open in the in-app viewer. +/// 8 KB sniff: NUL bytes or non-UTF-8 sequences disqualify. Future _looksLikeText(String path) async { final file = File(path); RandomAccessFile? raf; @@ -126,10 +117,8 @@ Future _looksLikeText(String path) async { } } -/// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal -/// LayoutBuilder calls `localToGlobal` during build, which asserts when an -/// ancestor RenderTransform (from the page-push animation) is still mid-layout. -/// We wait for the route's enter animation to complete before mounting it. +/// SfPdfViewer asserts on `localToGlobal` if mounted during the page-push +/// animation. Defer until the route enter animation completes. class _DeferredPdfViewer extends StatefulWidget { const _DeferredPdfViewer({required this.path}); final String path; @@ -189,8 +178,6 @@ class _FileViewerState extends State { settings.val().fileViewSettings.alwaysOpenExternally || widget.openExternal; if (openExternal) { - // Settings or popup explicitly chose "open externally" — fire and - // forget, then pop back. Same one-shot behaviour as the old viewer. WidgetsBinding.instance.addPostFrameCallback( (_) => _openExternallyAndPop(), ); @@ -256,13 +243,9 @@ class _FileViewerState extends State { try { final source = File(widget.path); final size = await source.length(); - // Hard-cap to avoid loading the entire file into memory just to - // hand it back to the platform's saveFile dialog. The package - // currently has no streaming/path-based save path, so for big - // media the user has to fall back to "Teilen" → save-to-files. - // 200 MB peak is comfortable on modern mid-range devices and big - // enough for typical school videos. - const maxBytes = 200 * 1024 * 1024; // 200 MB + // file_picker has no path/stream save API, so the whole file + // gets loaded into RAM. Cap big media; user falls back to share. + const maxBytes = 200 * 1024 * 1024; if (size > maxBytes) { if (!mounted) return; InfoDialog.show( @@ -298,8 +281,6 @@ class _FileViewerState extends State { List<_ActionDescriptor> _availableActions() => [ _ActionDescriptor( action: FileViewingActions.openExternal, - // iOS opens the system share sheet (square-with-arrow icon), Android - // the standard app picker; mirror that visually and verbally. icon: Platform.isIOS ? Icons.ios_share : Icons.open_in_new, label: Platform.isIOS ? 'Extern öffnen' : 'Öffnen mit', ), @@ -459,8 +440,7 @@ class _FileViewerState extends State { } final payload = snapshot.data!; final lines = const LineSplitter().convert(payload.content); - // Reserve gutter width by the digit count of the highest line number, - // so the gutter stays stable as the user scrolls down. + // Stable gutter width — sized by the highest line number's digit count. final gutterWidth = (lines.length.toString().length * 9.0) + 16; return SelectionArea( child: Scrollbar( @@ -564,8 +544,7 @@ class _FileViewerState extends State { final raf = await file.open(); try { final bytes = await raf.read(_textViewMaxBytes); - // Truncated payloads cannot be reliably re-formatted (parser will - // choke on the dangling tail), so they stay raw. + // Truncated payloads stay raw — a parser would choke on the dangling tail. return _TextPayload( content: utf8.decode(bytes, allowMalformed: true), truncated: true, @@ -575,9 +554,7 @@ class _FileViewerState extends State { } } - /// Re-indents JSON so dumped/minified payloads from the server are easier - /// to read. Falls through to the original text on parse errors so we - /// never destroy the user's content. + /// Falls through to the original text on parse errors. String _maybePrettify(String content, String ext) { if (ext != 'json') return content; try { @@ -606,10 +583,6 @@ class _TextPayload { const _TextPayload({required this.content, required this.truncated}); } -/// Plays back a local file via `video_player`. Renders the standard Chewie -/// controls for video files; audio files get a centered icon plus a custom -/// transport row (slider, time, play/pause), since Chewie's chrome is -/// designed around a video frame. class _MediaPlayer extends StatefulWidget { final String path; final bool isAudio; @@ -799,10 +772,6 @@ class _AudioControls extends StatelessWidget { } } -/// One row in the text viewer: line number on the left (not selectable so -/// it never ends up in copied selections), monospace content on the right. -/// Odd-numbered lines get a slightly tinted background so long files are -/// easier to scan. class _CodeLine extends StatelessWidget { final int number; final String text; diff --git a/lib/widget/user_avatar.dart b/lib/widget/user_avatar.dart index 804846e..5ef5271 100644 --- a/lib/widget/user_avatar.dart +++ b/lib/widget/user_avatar.dart @@ -36,15 +36,12 @@ class _AvatarCacheEntry { _AvatarCacheEntry(this.payload, this.fetchedAt); } -// Cap keeps the heap bounded for power-users in Talk; TTL ensures -// server-side avatar updates become visible within a session without -// requiring an app restart. LinkedHashMap insertion-order plus a remove -// on hit gives us LRU eviction. +// LRU via LinkedHashMap insertion order + remove-on-hit. TTL so +// server-side avatar updates become visible within a session. const int _kAvatarCacheMax = 256; const Duration _kAvatarCacheTtl = Duration(minutes: 30); -// Resolved payloads are cached so re-mounts render synchronously; in-flight -// requests are deduped so concurrent mounts share one HTTP call. +// Pending map dedups concurrent mounts onto a single HTTP call. final LinkedHashMap _resolvedAvatars = LinkedHashMap(); final Map> _pendingAvatars = {};