refactored internal documentation and simplified comments across chat BLoCs, file viewer, and navigation components

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