refactored internal documentation and simplified comments across chat BLoCs, file viewer, and navigation components
This commit is contained in:
+16
-43
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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 = {};
|
||||||
|
|||||||
Reference in New Issue
Block a user