diff --git a/lib/api/marianumcloud/talk/chat/long_poll_chat.dart b/lib/api/marianumcloud/talk/chat/long_poll_chat.dart new file mode 100644 index 0000000..80a58aa --- /dev/null +++ b/lib/api/marianumcloud/talk/chat/long_poll_chat.dart @@ -0,0 +1,69 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import '../../../errors/network_exception.dart'; +import '../../../errors/server_exception.dart'; +import '../../nextcloud_ocs.dart'; +import 'get_chat_params.dart'; +import 'get_chat_response.dart'; + +/// Long-poll variant of GetChat (`lookIntoFuture=1`). Bypasses [TalkApi] +/// because that layer treats non-2xx as errors, and we need 304 to be a +/// normal "no new messages" outcome. `setReadMarker=on` lets the server +/// move the read cursor whenever the call returns messages. +class LongPollChat { + final String chatToken; + final int lastKnownMessageId; + final int timeoutSeconds; + + LongPollChat({ + required this.chatToken, + required this.lastKnownMessageId, + this.timeoutSeconds = 30, + }); + + /// Returns the response, or `null` on HTTP 304 (server timeout, nothing new). + Future run() async { + final params = GetChatParams( + lookIntoFuture: GetChatParamsSwitch.on, + timeout: timeoutSeconds, + lastKnownMessageId: lastKnownMessageId, + includeLastKnown: GetChatParamsSwitch.off, + setReadMarker: GetChatParamsSwitch.on, + limit: 100, + ); + final uri = NextcloudOcs.uri( + 'apps/spreed/api/v1/chat/$chatToken', + queryParameters: params.toJson(), + ); + final headers = NextcloudOcs.headers(); + + final http.Response response; + try { + response = await http + .get(uri, headers: headers) + .timeout(Duration(seconds: timeoutSeconds + 15)); + } on TimeoutException catch (e) { + throw NetworkException.timeout(technicalDetails: 'LongPollChat $uri: $e'); + } on SocketException catch (e) { + throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}'); + } on http.ClientException catch (e) { + throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}'); + } + + final status = response.statusCode; + if (status == 304) return null; + if (status >= 200 && status < 300) { + final decoded = jsonDecode(response.body) as Map; + return GetChatResponse.fromJson(decoded['ocs'] as Map) + ..headers = response.headers; + } + throw ServerException( + statusCode: status, + technicalDetails: 'LongPollChat $uri: HTTP $status', + ); + } +} diff --git a/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart index 847a96d..85da8d5 100644 --- a/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart +++ b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart @@ -26,11 +26,5 @@ class SetReadMarker extends TalkApi { Uri uri, Object? body, Map? headers, - ) { - if (readState) { - return http.post(uri, headers: headers); - } else { - return http.delete(uri, headers: headers); - } - } + ) => readState ? http.post(uri, headers: headers) : http.delete(uri, headers: headers); } diff --git a/lib/app.dart b/lib/app.dart index 7a9b2f6..eb3b3eb 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -36,17 +36,33 @@ class App extends StatefulWidget { } class _AppState extends State with WidgetsBindingObserver { - late Timer _refetchChats; late Timer _updateTimings; StreamSubscription? _timetableWidgetSync; - // 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. + StreamSubscription? _onMessageSub; + StreamSubscription? _onMessageOpenedAppSub; + StreamSubscription? _fcmTokenRefreshSub; int _knownTotalTabs = 1; bool _userOnLastTab = false; + static const Duration _chatListActiveInterval = Duration(seconds: 15); + static const Duration _chatListIdleInterval = Duration(seconds: 60); + void _onTabControllerChanged() { _userOnLastTab = Main.bottomNavigator.index == _knownTotalTabs - 1; + _syncChatListPolling(); + } + + void _syncChatListPolling() { + if (!mounted) return; + final modules = AppModule.getBottomBarModules(context); + final talkSlot = modules.indexWhere((m) => m.module == Modules.talk); + final talkIsActive = + talkSlot >= 0 && Main.bottomNavigator.index == talkSlot; + final bloc = context.read(); + bloc.setAutoRefreshInterval( + talkIsActive ? _chatListActiveInterval : _chatListIdleInterval, + ); + if (talkIsActive) bloc.refresh(); } @override @@ -65,13 +81,12 @@ 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. + // `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); + navigator.popUntil((route) => route.isFirst || route is PopupRoute); } AppRoutes.goToTab(context, Modules.timetable); } @@ -80,12 +95,11 @@ 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. + // 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); + navigator.popUntil((route) => route.isFirst || route is PopupRoute); } AppRoutes.openShareTarget(context, share); } @@ -101,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) { @@ -123,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( @@ -138,28 +147,24 @@ class _AppState extends State with WidgetsBindingObserver { ShareIntentListener.instance.attach(); ShareIntentListener.pending.addListener(_handlePendingShare); _handlePendingShare(); + _syncChatListPolling(); }); _updateTimings = Timer.periodic(const Duration(seconds: 30), (_) { if (mounted) setState(() {}); }); - _refetchChats = Timer.periodic(const Duration(seconds: 60), (_) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - context.read().refresh(); - }); - }); - UpdateUserIndex.index(); if (context.read().val().notificationSettings.enabled) { void update() => NotifyUpdater.registerToServer(); - FirebaseMessaging.instance.onTokenRefresh.listen((_) => update()); + _fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen( + (_) => update(), + ); update(); } - FirebaseMessaging.onMessage.listen((message) { + _onMessageSub = FirebaseMessaging.onMessage.listen((message) { if (!mounted) return; NotificationController.onForegroundMessageHandler(message, context); }); @@ -167,7 +172,9 @@ class _AppState extends State with WidgetsBindingObserver { NotificationController.onBackgroundMessageHandler, ); - FirebaseMessaging.onMessageOpenedApp.listen((message) { + _onMessageOpenedAppSub = FirebaseMessaging.onMessageOpenedApp.listen(( + message, + ) { if (!mounted) return; NotificationController.onAppOpenedByNotification(message, context); }); @@ -181,9 +188,11 @@ class _AppState extends State with WidgetsBindingObserver { @override void dispose() { - _refetchChats.cancel(); _updateTimings.cancel(); _timetableWidgetSync?.cancel(); + _onMessageSub?.cancel(); + _onMessageOpenedAppSub?.cancel(); + _fcmTokenRefreshSub?.cancel(); ShareIntentListener.pending.removeListener(_handlePendingShare); ShareIntentListener.instance.detach(); Main.bottomNavigator.removeListener(_onTabControllerChanged); @@ -200,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', ); @@ -222,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( @@ -263,14 +260,17 @@ 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( - border: const Border(top: BorderSide(width: 1, color: Colors.grey)), + border: Border( + top: BorderSide( + width: 1, + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), color: Theme.of(context).colorScheme.surface, ), ), diff --git a/lib/main.dart b/lib/main.dart index f003df9..8ea5ab0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,6 +23,7 @@ import 'app.dart'; import 'background/widget_background_task.dart'; import 'firebase_options.dart'; import 'model/account_data.dart'; +import 'routing/app_routes.dart'; import 'share_intent/share_intent_listener.dart'; import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_state.dart'; @@ -153,7 +154,9 @@ Future main() async { ), BlocProvider(create: (_) => BreakerBloc()), BlocProvider(create: (_) => ChatListBloc()), - BlocProvider(create: (_) => ChatBloc()), + BlocProvider( + create: (ctx) => ChatBloc(chatListBloc: ctx.read()), + ), BlocProvider(create: (_) => TimetableBloc()), ], child: const Main(), @@ -199,6 +202,8 @@ class _MainState extends State
{ checkerboardRasterCacheImages: devToolsSettings.checkerboardRasterCacheImages, debugShowCheckedModeBanner: false, + // Used by ChatView.didPopNext to reclaim the global ChatBloc. + navigatorObservers: [AppRoutes.chatRouteObserver], localizationsDelegates: const [ ...GlobalMaterialLocalizations.delegates, GlobalWidgetsLocalizations.delegate, diff --git a/lib/notification/notification_controller.dart b/lib/notification/notification_controller.dart index 88cf348..fafe77b 100644 --- a/lib/notification/notification_controller.dart +++ b/lib/notification/notification_controller.dart @@ -1,13 +1,19 @@ +import 'dart:async'; + import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../widget/debug/debug_tile.dart'; import '../widget/debug/json_viewer.dart'; import '../widget/info_dialog.dart'; import 'notification_tasks.dart'; +// `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); @@ -17,8 +23,26 @@ class NotificationController { RemoteMessage message, BuildContext context, ) async { - NotificationTasks.updateProviders(context); + final pushToken = _extractChatToken(message); + final chatBloc = context.read(); + // 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 && + pushToken != null && + pushToken.isNotEmpty && + pushToken == activeToken; + NotificationTasks.updateBadgeCount(message); + + if (chatIsOpen) { + // Long-poll handles the message; just dismiss any stray tray entry. + unawaited(NotificationTasks.clearNotificationsForChat(pushToken)); + return; + } + + NotificationTasks.updateProviders(context); } static Future onAppOpenedByNotification( diff --git a/lib/notification/notification_tasks.dart b/lib/notification/notification_tasks.dart index 9a2f167..12dc16d 100644 --- a/lib/notification/notification_tasks.dart +++ b/lib/notification/notification_tasks.dart @@ -1,11 +1,15 @@ +import 'dart:developer'; + +import 'package:eraser/eraser.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../routing/app_routes.dart'; -import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import 'notification_service.dart'; class NotificationTasks { static void updateBadgeCount(RemoteMessage notification) { @@ -14,9 +18,43 @@ class NotificationTasks { ); } + /// Per-chat tag scheme. MUST match the Notify backend, which sets this + /// value on `AndroidNotification.setTag` AND `apns-collapse-id`. + static String chatTag(String chatToken) => 'talk_$chatToken'; + + /// Removes tray notifications belonging to [chatToken]. Eraser handles + /// iOS (where the plugin's `getActiveNotifications` returns null ids + /// for FCM posts and can't cancel them); the local-notifications sweep + /// handles Android and acts as a fallback while Eraser's native side + /// isn't built in yet. + static Future clearNotificationsForChat(String chatToken) async { + final tag = chatTag(chatToken); + try { + await Eraser.clearAppNotificationsByTag(tag); + } on MissingPluginException { + // Eraser native code not yet linked — needs flutter clean + run. + } on Object catch (e) { + log('Eraser($tag) failed: $e'); + } + try { + final plugin = NotificationService().flutterLocalNotificationsPlugin; + final actives = await plugin.getActiveNotifications(); + for (final n in actives) { + final id = n.id; + if (id == null) continue; + if (n.tag == tag) await plugin.cancel(id: id, tag: n.tag); + } + } on Object catch (e) { + log('Active-notification sweep failed: $e'); + } + } + + /// Refreshes the chat list. Deliberately does NOT touch [ChatBloc] — + /// the open chat view manages its own state via long-poll, and refreshing + /// it here would re-fetch the last-opened chat with setReadMarker=on + /// even if the user has already left. static void updateProviders(BuildContext context) { context.read().refresh(); - context.read().refresh(); } /// Switches to the Talk tab. If [chatToken] is provided, also schedules diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 804d94e..69803d6 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -6,6 +6,7 @@ import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import '../api/marianumcloud/talk/room/get_room_response.dart'; import '../main.dart'; import '../model/account_data.dart'; +import '../notification/notification_tasks.dart'; import '../share_intent/pending_share.dart'; import '../share_intent/remote_file_ref.dart'; import '../state/app/modules/app_modules.dart'; @@ -39,6 +40,11 @@ class AppRoutes { /// by `ChatList` once the matching room is loaded. static final ValueNotifier pendingChatToken = ValueNotifier(null); + /// Root-navigator observer used by [ChatView] to reclaim the global + /// [ChatBloc] on `didPopNext` after a stacked chat is popped. + static final RouteObserver> chatRouteObserver = + RouteObserver>(); + static void openFolder(BuildContext context, List path) { pushScreen(context, withNavBar: false, screen: Files(path: path)); } @@ -177,6 +183,12 @@ class AppRoutes { required UserAvatar avatar, bool overrideToSingleSubScreen = true, }) { + // Local mark only. Server-side mark is sent later from + // ChatBloc._loadChat with the freshly-fetched maxId — sending one + // here too with the chat list's possibly-stale room.lastMessage.id + // would race the fresh one and could regress the server cursor. + context.read().markRoomAsRead(room.token, room.lastMessage.id); + NotificationTasks.clearNotificationsForChat(room.token); TalkNavigator.pushSplitView( context, ChatView(room: room, selfId: selfId, avatar: avatar), diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index a79d169..8ccfb9e 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -1,15 +1,53 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + import '../../../../../api/errors/error_mapper.dart'; +import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart'; +import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart'; +import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart'; import '../../../infrastructure/loadable_state/loading_error.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import '../../chat_list/bloc/chat_list_bloc.dart'; import '../repository/chat_repository.dart'; import 'chat_event.dart'; import 'chat_state.dart'; class ChatBloc - extends LoadableHydratedBloc { + extends LoadableHydratedBloc + with WidgetsBindingObserver { + final ChatListBloc? _chatListBloc; + + String? _pollingToken; + int _backoffMs = 0; + int _lastKnownMessageId = 0; + bool _appResumed = true; + + /// 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; + + bool get hasOpenChat => _chatViewActive; + DateTime _lastTokenSet = DateTime.fromMillisecondsSinceEpoch(0); + ChatBloc({ChatListBloc? chatListBloc}) : _chatListBloc = chatListBloc { + WidgetsBinding.instance.addObserver(this); + } + + @override + Future close() { + WidgetsBinding.instance.removeObserver(this); + _stopLongPoll(); + return super.close(); + } + @override ChatRepository repository() => ChatRepository(); @@ -33,24 +71,70 @@ class ChatBloc } void setToken(String token) { + _chatViewActive = true; if (token == (innerState?.currentToken ?? '')) { refresh(); return; } + _stopLongPoll(); add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null))); add(RefetchStarted()); - _loadChat(token); - } - - void setReferenceMessageId(int? messageId) { - add(Emit((s) => s.copyWith(referenceMessageId: messageId))); + _scheduleLoad(token); } void refresh() { final token = innerState?.currentToken ?? ''; if (token.isEmpty) return; add(RefetchStarted()); - _loadChat(token); + _scheduleLoad(token); + } + + void setReferenceMessageId(int? messageId) { + add(Emit((s) => s.copyWith(referenceMessageId: messageId))); + } + + /// 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(); + } + + Future sendServerReadMarker(String token, int messageId) async { + try { + await SetReadMarker( + token, + true, + setReadMarkerParams: SetReadMarkerParams(lastReadMessage: messageId), + ).run(); + } on Object catch (e) { + log('Server read-marker for $token failed: $e'); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final wasResumed = _appResumed; + _appResumed = state == AppLifecycleState.resumed; + if (!_appResumed) { + _stopLongPoll(); + return; + } + if (wasResumed) return; + final token = innerState?.currentToken ?? ''; + if (token.isNotEmpty && _chatViewActive) refresh(); + } + + /// 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; + _loadChat(token).then((_) => _startLongPoll(token)); + }); } Future _loadChat(String token) async { @@ -69,14 +153,25 @@ class ChatBloc token: token, onCacheData: (data) { if (!stillCurrent()) return; - // Cache hit: show data immediately but preserve lastFetch — the - // cached payload may be stale and we don't want the UI to claim a - // fresh fetch just happened. + // 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) { + // 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); + if (cached == null || cached < maxId) { + unawaited(sendServerReadMarker(token, maxId)); + } + } if (!stillCurrent()) return; - add(DataGathered((s) => s.copyWith(chatResponse: data))); + _applyChatResponse(data); + if (maxId > 0) _chatListBloc?.markRoomAsRead(token, maxId); }, onError: (e) => capturedError = e, ); @@ -98,4 +193,106 @@ class ChatBloc ); } } + + void _startLongPoll(String token) { + if (!_appResumed) return; + if (_pollingToken == token) return; + _stopLongPoll(); + _pollingToken = token; + _backoffMs = 0; + _lastKnownMessageId = _maxMessageId(innerState?.chatResponse); + unawaited(_pollLoop(token)); + } + + void _stopLongPoll() { + _pollingToken = null; + _backoffMs = 0; + } + + Future _pollLoop(String token) async { + while (_pollingToken == token && !isClosed) { + try { + final response = await LongPollChat( + chatToken: token, + lastKnownMessageId: _lastKnownMessageId, + ).run(); + + if (_pollingToken != token || isClosed) return; + _backoffMs = 0; + + if (response == null) continue; + + final headerId = int.tryParse( + response.headers?[_kLongPollLastGivenHeader] ?? '', + ); + if (headerId != null && headerId > _lastKnownMessageId) { + _lastKnownMessageId = headerId; + } + + if (response.data.isEmpty) continue; + _applyChatResponse(response); + final maxId = _maxMessageId(response); + if (maxId > _lastKnownMessageId) _lastKnownMessageId = maxId; + // Long-poll's setReadMarker=on moved the server cursor; mirror locally. + final preview = _pickDisplayMessage(response); + if (preview != null) { + _chatListBloc?.applyIncomingMessage(token, preview); + } else { + _chatListBloc?.markRoomAsRead(token, _lastKnownMessageId); + } + } on Object catch (e) { + if (_pollingToken != token || isClosed) return; + log('LongPoll error for $token: $e'); + _backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000); + await Future.delayed(Duration(milliseconds: _backoffMs)); + } + } + } + + /// Dedups by id with newer-wins so server edits/deletes propagate. + void _applyChatResponse(GetChatResponse incoming) { + final current = innerState?.chatResponse; + if (current == null) { + add(DataGathered((s) => s.copyWith(chatResponse: incoming))); + return; + } + final byId = {}; + for (final m in current.data) { + byId[m.id] = m; + } + for (final m in incoming.data) { + byId[m.id] = m; + } + final merged = GetChatResponse(byId.values.toSet()) + ..headers = incoming.headers; + add(DataGathered((s) => s.copyWith(chatResponse: merged))); + } + + int _maxMessageId(GetChatResponse? response) { + if (response == null) return 0; + var max = 0; + for (final m in response.data) { + if (m.id > max) max = m.id; + } + return max; + } + + /// Mirrors the server's own `lastMessage` selection (comments + voice only). + GetChatResponseObject? _pickDisplayMessage(GetChatResponse response) { + GetChatResponseObject? best; + for (final m in response.data) { + switch (m.messageType) { + case GetRoomResponseObjectMessageType.comment: + case GetRoomResponseObjectMessageType.voiceMessage: + if (best == null || m.id > best.id) best = m; + case GetRoomResponseObjectMessageType.deletedComment: + case GetRoomResponseObjectMessageType.system: + case GetRoomResponseObjectMessageType.command: + break; + } + } + return best; + } } + +const _kLongPollLastGivenHeader = 'x-chat-last-given'; 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 d05895d..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 @@ -1,8 +1,10 @@ +import 'dart:async'; import 'dart:developer'; import 'package:flutter_app_badge/flutter_app_badge.dart'; import '../../../../../api/errors/error_mapper.dart'; +import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../infrastructure/loadable_state/loading_error.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; @@ -15,6 +17,8 @@ class ChatListBloc extends LoadableHydratedBloc { bool _forceRenew = false; + Timer? _autoRefreshTimer; + Duration? _autoRefreshInterval; @override void retry() { @@ -22,6 +26,25 @@ class ChatListBloc super.retry(); } + @override + Future close() { + _autoRefreshTimer?.cancel(); + return super.close(); + } + + /// Silent refresh — explicit pull-to-refresh and tab-activation are non-silent. + void setAutoRefreshInterval(Duration? interval) { + if (interval == _autoRefreshInterval) return; + _autoRefreshInterval = interval; + _autoRefreshTimer?.cancel(); + _autoRefreshTimer = null; + if (interval == null) return; + _autoRefreshTimer = Timer.periodic(interval, (_) { + if (isClosed) return; + refresh(silent: true); + }); + } + @override ChatListRepository repository() => ChatListRepository(); @@ -51,8 +74,8 @@ class ChatListBloc if (capturedError != null) throw capturedError!; } - Future refresh({bool renew = true}) async { - add(RefetchStarted()); + Future refresh({bool renew = true, bool silent = false}) async { + if (!silent) add(RefetchStarted()); Object? capturedError; try { final rooms = await repo.data.getRooms( @@ -82,6 +105,65 @@ class ChatListBloc await refresh(); } + int? lastReadMessageFor(String token) { + final rooms = innerState?.rooms; + if (rooms == null) return null; + for (final room in rooms.data) { + if (room.token == token) return room.lastReadMessage; + } + return null; + } + + /// 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) { + return false; + } + r.unreadMessages = 0; + r.unreadMention = false; + r.unreadMentionDirect = false; + if (lastMessageId > r.lastReadMessage) r.lastReadMessage = lastMessageId; + return true; + }); + } + + /// 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 = + r.unreadMessages == 0 && r.lastReadMessage >= message.id; + final hasNewer = r.lastMessage.id >= message.id; + if (wasRead && hasNewer) return false; + r.unreadMessages = 0; + r.unreadMention = false; + r.unreadMentionDirect = false; + if (message.id > r.lastReadMessage) r.lastReadMessage = message.id; + if (message.id > r.lastMessage.id) r.lastMessage = message; + if (message.timestamp > r.lastActivity) r.lastActivity = message.timestamp; + return true; + }); + } + + /// Re-wraps in a fresh [GetRoomResponse] so identity-based equality picks it up. + void _mutateRoom( + String token, + bool Function(GetRoomResponseObject room) mutator, + ) { + final rooms = innerState?.rooms; + if (rooms == null) return; + var changed = false; + final updated = rooms.data.map((r) { + if (r.token != token) return r; + if (mutator(r)) changed = true; + return r; + }).toSet(); + if (!changed) return; + final newRooms = GetRoomResponse(updated)..headers = rooms.headers; + add(Emit((s) => s.copyWith(rooms: newRooms))); + _updateAppBadge(newRooms); + } + void _updateAppBadge(GetRoomResponse rooms) { try { final unread = rooms.data.fold( diff --git a/lib/view/pages/marianum_dates/widgets/event_list_tile.dart b/lib/view/pages/marianum_dates/widgets/event_list_tile.dart index 8fb09f9..86ef73a 100644 --- a/lib/view/pages/marianum_dates/widgets/event_list_tile.dart +++ b/lib/view/pages/marianum_dates/widgets/event_list_tile.dart @@ -102,6 +102,7 @@ class MarianumDateRow extends StatelessWidget { initialDescription: event.description, initialStart: event.start, initialEnd: event.end, + initialAllDay: event.isAllDay, ), barrierDismissible: false, ), diff --git a/lib/view/pages/settings/sections/about_section.dart b/lib/view/pages/settings/sections/about_section.dart index b2fa812..9b18443 100644 --- a/lib/view/pages/settings/sections/about_section.dart +++ b/lib/view/pages/settings/sections/about_section.dart @@ -67,12 +67,12 @@ class AboutSection extends StatelessWidget { applicationIcon: const Icon(Icons.apps), applicationName: 'MarianumMobile', applicationVersion: - '${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}', + '${appInfo.appName}\n\n${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild/Relase-nummer: ${appInfo.buildNumber}', applicationLegalese: 'Dies ist ein Inoffizieller Marianum-Cloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n' 'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n' - "${kReleaseMode ? "Production" : "Development"} build\n" - 'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller', + "${kReleaseMode ? "Production" : "Development ${kProfileMode ? "(Profiling)" : "(Debug)"}"} build.\n\n" + 'Marianum Fulda 2019-2020, 2023-${Jiffy.now().year}\nElias Müller', ); } @@ -92,7 +92,7 @@ class AboutSection extends StatelessWidget { ), ListTile( leading: const CenteredLeading(Icon(Icons.date_range_outlined)), - title: const Text('Infos zu Web-/ Untis'), + title: const Text('Infos zu (Web) Untis'), subtitle: const Text('Für den Stundenplan'), trailing: const Icon(Icons.arrow_right), onTap: () => PrivacyInfo( @@ -106,7 +106,7 @@ class AboutSection extends StatelessWidget { Icon(Icons.send_time_extension_outlined), ), title: const Text('Infos zu mhsl'), - subtitle: const Text('Für Countdowns, Marianum Message und mehr'), + subtitle: const Text('Für Push, Kalendertermine, Marianum Message und mehr'), trailing: const Icon(Icons.arrow_right), onTap: () => PrivacyInfo( providerText: 'mhsl', diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index 0eb72c8..d249b64 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -7,7 +7,6 @@ import '../../../notification/notify_updater.dart'; import '../../../routing/app_routes.dart'; import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; -import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; @@ -19,15 +18,13 @@ import 'search_chat.dart'; import 'widgets/chat_tile.dart'; import 'widgets/split_view_placeholder.dart'; +// Reads from the global ChatListBloc in main.dart — re-providing a local +// one here would shadow it and split the state in two. class ChatList extends StatelessWidget { const ChatList({super.key}); @override - Widget build(BuildContext context) => - BlocModule>( - create: (_) => ChatListBloc(), - child: (context, bloc, _) => const _ChatListView(), - ); + Widget build(BuildContext context) => const _ChatListView(); } class _ChatListView extends StatefulWidget { @@ -65,14 +62,6 @@ class _ChatListViewState extends State<_ChatListView> { final resolved = AppRoutes.resolvePendingChat(context); if (resolved == null) return; AppRoutes.pendingChatToken.value = null; - - // Replace any chat already pushed on top of the chat list so a freshly - // tapped notification doesn't stack indefinitely on previous chats. - final navigator = Navigator.of(context); - if (navigator.canPop()) { - navigator.popUntil((route) => route.isFirst); - } - AppRoutes.openChatView( context, room: resolved.room, @@ -193,7 +182,14 @@ class _ChatListViewState extends State<_ChatListView> { .talkSettings .drafts .containsKey(room.token); - return ChatTile(data: room, hasDraft: hasDraft); + // Stable key keeps element identity across re-sorts so the + // inner UserAvatar reuses its cached bytes instead of + // flashing on every list update. + return ChatTile( + key: ValueKey(room.token), + data: room, + hasDraft: hasDraft, + ); }).toList(), ); }, diff --git a/lib/view/pages/talk/chat_view.dart b/lib/view/pages/talk/chat_view.dart index ff92ecc..900b222 100644 --- a/lib/view/pages/talk/chat_view.dart +++ b/lib/view/pages/talk/chat_view.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -7,9 +8,12 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../extensions/date_time.dart'; +import '../../../notification/notification_tasks.dart'; +import '../../../routing/app_routes.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../state/app/modules/chat/bloc/chat_state.dart'; +import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../theming/app_theme.dart'; import '../../../widget/clickable_app_bar.dart'; import '../../../widget/user_avatar.dart'; @@ -36,7 +40,7 @@ class ChatView extends StatefulWidget { State createState() => _ChatViewState(); } -class _ChatViewState extends State { +class _ChatViewState extends State with RouteAware { final ItemScrollController _itemScrollController = ItemScrollController(); final TextEditingController _searchTextController = TextEditingController(); final Map _matchIndices = {}; @@ -48,12 +52,73 @@ class _ChatViewState extends State { GetChatResponse? _matchesComputedFor; String? _matchesComputedQuery; + // Captured in initState because the framework has unmounted us by the + // time dispose runs. + ChatBloc? _chatBlocRef; + ChatListBloc? _chatListBlocRef; + PageRoute? _subscribedRoute; + + @override + void initState() { + super.initState(); + _chatBlocRef = context.read(); + _chatListBlocRef = context.read(); + NotificationTasks.clearNotificationsForChat(widget.room.token); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route is PageRoute && route != _subscribedRoute) { + if (_subscribedRoute != null) { + AppRoutes.chatRouteObserver.unsubscribe(this); + } + AppRoutes.chatRouteObserver.subscribe(this, route); + _subscribedRoute = route; + } + } + + @override + void didPopNext() { + super.didPopNext(); + // A stacked chat above us was just popped (typical: notification tap + // opened another chat). The global ChatBloc currently points at that + // other chat's token, so our isReady predicate fails until we re-claim. + _chatBlocRef?.setToken(widget.room.token); + } + @override void dispose() { + if (_subscribedRoute != null) { + AppRoutes.chatRouteObserver.unsubscribe(this); + } + _markAsReadFinal(); + _chatBlocRef?.leaveChat(widget.room.token); _searchTextController.dispose(); super.dispose(); } + /// Defensive final mark-as-read so a back-out before the long-poll + /// could fire doesn't leave the room as unread. Skipped when the bloc + /// has already moved on to another chat — the response data there + /// belongs to a different room, and writing its max-id as our marker + /// would regress our server cursor. + void _markAsReadFinal() { + final state = _chatBlocRef?.state.data; + if (state == null) return; + if (state.currentToken != widget.room.token) return; + final response = state.chatResponse; + if (response == null) return; + var maxId = 0; + for (final m in response.data) { + if (m.id > maxId) maxId = m.id; + } + if (maxId == 0) return; + _chatListBlocRef?.markRoomAsRead(widget.room.token, maxId); + unawaited(_chatBlocRef!.sendServerReadMarker(widget.room.token, maxId)); + } + @override void didUpdateWidget(covariant ChatView oldWidget) { super.didUpdateWidget(oldWidget); 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 c7e0fea..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,10 +180,12 @@ 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). - Navigator.of(context).popUntil((route) => route.isFirst); + // 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); AppRoutes.openChatByToken(context, room.token); } diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index bea3098..67a413b 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -7,9 +7,10 @@ import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart'; -import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart'; import '../../../../extensions/date_time.dart'; import '../../../../model/account_data.dart'; +import '../../../../notification/notification_tasks.dart'; +import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../widget/async_action_button.dart'; @@ -17,7 +18,6 @@ import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/user_avatar.dart'; -import '../chat_view.dart'; import '../talk_navigator.dart'; class ChatTile extends StatefulWidget { @@ -61,13 +61,11 @@ class _ChatTileState extends State { void _refreshList() => context.read().refresh(); Future _setCurrentAsRead() async { - await SetReadMarker( - widget.data.token, - true, - setReadMarkerParams: SetReadMarkerParams( - lastReadMessage: widget.data.lastMessage.id, - ), - ).run(); + final token = widget.data.token; + final lastId = widget.data.lastMessage.id; + context.read().markRoomAsRead(token, lastId); + unawaited(NotificationTasks.clearNotificationsForChat(token)); + await context.read().sendServerReadMarker(token, lastId); if (!mounted) return; _refreshList(); } @@ -154,18 +152,17 @@ class _ChatTileState extends State { return; } if (selfUsername == null) return; - unawaited(_setCurrentAsRead()); - final view = ChatView( + // openChatView is the single entry point for opening a chat — + // it handles optimistic mark-as-read, tray cleanup, push, and + // setToken in one place so the notification-tap path gets the + // same treatment as a tile tap. + AppRoutes.openChatView( + context, room: widget.data, selfId: selfUsername!, avatar: circleAvatar, - ); - TalkNavigator.pushSplitView( - context, - view, overrideToSingleSubScreen: true, ); - context.read().setToken(widget.data.token); }, onLongPress: () { if (widget.disableContextActions) return; diff --git a/lib/view/pages/talk/widgets/highlighted_linkify.dart b/lib/view/pages/talk/widgets/highlighted_linkify.dart index 026b5a4..e335f91 100644 --- a/lib/view/pages/talk/widgets/highlighted_linkify.dart +++ b/lib/view/pages/talk/widgets/highlighted_linkify.dart @@ -78,34 +78,53 @@ class HighlightedLinkify extends StatefulWidget { } class _HighlightedLinkifyState extends State { - final List _recognizers = []; + // Cached per link text — search rebuilds keystroke-by-keystroke + // would otherwise churn allocate/dispose. Pruned via [_seenLinkKeys]. + final Map _recognizers = {}; + final Set _seenLinkKeys = {}; @override void dispose() { - for (final r in _recognizers) { + for (final r in _recognizers.values) { r.dispose(); } + _recognizers.clear(); super.dispose(); } + TapGestureRecognizer _recognizerFor(LinkableElement el) { + final key = el.text; + final existing = _recognizers[key]; + if (existing != null) { + // Refresh onTap so a parent rebuild's new closure is picked up. + existing.onTap = () => widget.onOpen?.call(el); + return existing; + } + final created = TapGestureRecognizer() + ..onTap = () => widget.onOpen?.call(el); + _recognizers[key] = created; + return created; + } + + void _pruneUnseen() { + final stale = _recognizers.keys + .where((k) => !_seenLinkKeys.contains(k)) + .toList(growable: false); + for (final k in stale) { + _recognizers.remove(k)?.dispose(); + } + } + @override Widget build(BuildContext context) { - for (final r in _recognizers) { - r.dispose(); - } - _recognizers.clear(); + _seenLinkKeys.clear(); 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( @@ -124,9 +143,8 @@ class _HighlightedLinkifyState extends State { for (final el in elements) { if (el is LinkableElement) { - final recognizer = TapGestureRecognizer() - ..onTap = () => widget.onOpen?.call(el); - _recognizers.add(recognizer); + _seenLinkKeys.add(el.text); + final recognizer = _recognizerFor(el); spans.addAll( buildHighlightedSpans( text: el.text, @@ -147,6 +165,8 @@ class _HighlightedLinkifyState extends State { } } + _pruneUnseen(); + return Text.rich(TextSpan(children: spans)); } } diff --git a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart index 8dd64f2..cbb3754 100644 --- a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart +++ b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart @@ -19,6 +19,7 @@ class CustomEventEditDialog extends StatefulWidget { final DateTime? initialEnd; final String? initialTitle; final String? initialDescription; + final bool? initialAllDay; const CustomEventEditDialog({ this.existingEvent, @@ -26,6 +27,7 @@ class CustomEventEditDialog extends StatefulWidget { this.initialEnd, this.initialTitle, this.initialDescription, + this.initialAllDay, super.key, }); @@ -78,12 +80,17 @@ class _CustomEventEditDialogState extends State { } return; } - _isAllDay = false; - final rawStart = widget.initialStart?.toTimeOfDay() ?? _defaultStart; - final rawEnd = widget.initialEnd?.toTimeOfDay() ?? _defaultEnd; - final clamped = _clampToVisibleWindow(rawStart, rawEnd); - _startTime = clamped.$1; - _endTime = clamped.$2; + _isAllDay = widget.initialAllDay ?? false; + if (_isAllDay) { + _startTime = _defaultStart; + _endTime = _defaultEnd; + } else { + final rawStart = widget.initialStart?.toTimeOfDay() ?? _defaultStart; + final rawEnd = widget.initialEnd?.toTimeOfDay() ?? _defaultEnd; + final clamped = _clampToVisibleWindow(rawStart, rawEnd); + _startTime = clamped.$1; + _endTime = clamped.$2; + } } static (TimeOfDay, TimeOfDay) _clampToVisibleWindow( diff --git a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart index a5cd101..62c7d15 100644 --- a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart +++ b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; @@ -11,7 +10,6 @@ import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/details_bottom_sheet.dart'; -import '../../../../widget/unimplemented_dialog.dart'; class WebuntisLessonSheet { static void show( @@ -72,7 +70,7 @@ class WebuntisLessonSheet { }).toList(), ), _roomTile(context, state, lesson), - _teacherTile(context, lesson), + _teacherTile(lesson), if ((lesson.activityType ?? '').trim().isNotEmpty) ListTile( leading: const Icon(Icons.abc), @@ -120,14 +118,15 @@ class WebuntisLessonSheet { final name = firstNonEmpty([resolved.name, r.name, '?']); final longname = firstNonEmpty([resolved.longName, r.longname, '']); final building = resolved.building.trim(); - return LessonFormatter.formatLine( + final main = LessonFormatter.formatLine( name, - longname: longname, extra: (building.isNotEmpty && building != '?') ? building : null, ); + final sub = (longname.isNotEmpty && longname != name) ? longname : null; + return (main: main, sub: sub); }).toList(); - return _listTile( + return _listTileWithSubs( icon: Icons.room, label: lesson.ro.length == 1 ? 'Raum' : 'Räume', entries: entries, @@ -135,39 +134,63 @@ class WebuntisLessonSheet { ); } - static Widget _teacherTile( - BuildContext context, - GetTimetableResponseObject lesson, - ) { - final trailing = Visibility( - visible: !kReleaseMode, - child: IconButton( - icon: const Icon(Icons.textsms_outlined), - onPressed: () => UnimplementedDialog.show(context), - ), - ); - + static Widget _teacherTile(GetTimetableResponseObject lesson) { if (lesson.te.isEmpty) { - return ListTile( - leading: const Icon(Icons.person), - title: const Text('Lehrkraft: ?'), - trailing: trailing, + return const ListTile( + leading: Icon(Icons.person), + title: Text('Lehrkraft: ?'), ); } final entries = lesson.te.map((t) { - final base = LessonFormatter.formatLine( + final main = LessonFormatter.formatLine( t.name.isNotEmpty ? t.name : '?', longname: t.longname, ); final orgname = (t.orgname ?? '').trim(); - return orgname.isEmpty ? base : '$base · ehemals $orgname'; + return (main: main, sub: orgname.isEmpty ? null : 'ehemals $orgname'); }).toList(); - return _listTile( + return _listTileWithSubs( icon: Icons.person, label: lesson.te.length == 1 ? 'Lehrkraft' : 'Lehrkräfte', entries: entries, + ); + } + + static Widget _listTileWithSubs({ + required IconData icon, + required String label, + required List<({String main, String? sub})> entries, + Widget? trailing, + }) { + if (entries.length == 1) { + final e = entries.first; + return ListTile( + leading: Icon(icon), + title: Text('$label: ${e.main}'), + subtitle: e.sub != null ? Text(e.sub!) : null, + trailing: trailing, + ); + } + return ListTile( + leading: Icon(icon), + title: Text(label), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: entries + .expand( + (e) => [ + Text(e.main), + if (e.sub != null) + Padding( + padding: const EdgeInsets.only(left: 12), + child: Text(e.sub!), + ), + ], + ) + .toList(), + ), trailing: trailing, ); } diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart index 61d08c0..2931630 100644 --- a/lib/view/pages/timetable/widgets/appointment_tile.dart +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -37,40 +37,10 @@ class AppointmentTile extends StatelessWidget { borderRadius: _radius, color: color, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.max, - children: [ - _AdaptiveTitle( - text: appointment.subject, - fontSize: kAppointmentTitleFontSize, - minFontSize: kAppointmentTitleMinFontSize, - fontWeight: FontWeight.w500, - ), - if (isCustom) ...[ - if (description.isNotEmpty) - Expanded( - child: Padding( - padding: const EdgeInsets.only(top: 1), - child: _WrappingBody( - text: description, - fontSize: kAppointmentBodyFontSize, - lineHeight: kAppointmentBodyLineHeight, - ), - ), - ), - ] else ...[ - for (final line - in description - .split('\n') - .where((p) => p.isNotEmpty) - .take(2)) - _ScaledLine( - text: line, - fontSize: kAppointmentBodyFontSize, - ), - ], - ], + child: _TileContent( + title: appointment.subject, + description: description, + isCustom: isCustom, ), ), ), @@ -96,6 +66,91 @@ class AppointmentTile extends StatelessWidget { } } +/// Picks how many lines fit into the calendar slot's height. Title gets +/// first dibs; if not even one minimum-size title line fits, the column +/// collapses to keep the slot from overflowing. +class _TileContent extends StatelessWidget { + final String title; + final String description; + final bool isCustom; + + const _TileContent({ + required this.title, + required this.description, + required this.isCustom, + }); + + @override + Widget build(BuildContext context) { + final scaler = MediaQuery.textScalerOf(context); + final titleLineHeight = scaler.scale(kAppointmentTitleMinFontSize) * 1.1; + final bodyLineHeight = scaler.scale(kAppointmentBodyFontSize) * 1.1; + + final titleWidget = _AdaptiveTitle( + text: title, + fontSize: kAppointmentTitleFontSize, + minFontSize: kAppointmentTitleMinFontSize, + fontWeight: FontWeight.w500, + ); + + return LayoutBuilder( + builder: (context, constraints) { + final available = constraints.maxHeight; + // Slot too short for even one min-size title line — drop text + // entirely; the coloured rectangle is enough. + if (available < titleLineHeight) return const SizedBox.shrink(); + + final remaining = + (available - titleLineHeight).clamp(0.0, double.infinity); + final bodyLineCapacity = (remaining / bodyLineHeight).floor(); + + if (isCustom) { + if (description.isEmpty || bodyLineCapacity <= 0) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [titleWidget], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.max, + children: [ + titleWidget, + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 1), + child: _WrappingBody( + text: description, + fontSize: kAppointmentBodyFontSize, + lineHeight: kAppointmentBodyLineHeight, + ), + ), + ), + ], + ); + } + + final maxBodyLines = bodyLineCapacity.clamp(0, 2); + final lines = description + .split('\n') + .where((p) => p.isNotEmpty) + .take(maxBodyLines) + .toList(growable: false); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + titleWidget, + for (final line in lines) + _ScaledLine(text: line, fontSize: kAppointmentBodyFontSize), + ], + ); + }, + ); + } +} + /// Renders the appointment title. Scales down to fit the available width via /// [FittedBox], but never below [minFontSize] — when even the minimum size /// overflows, the text is rendered at [minFontSize] with an ellipsis. diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index 55b09ef..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(), ); @@ -254,7 +241,22 @@ class _FileViewerState extends State { break; case FileViewingActions.save: try { - final bytes = await File(widget.path).readAsBytes(); + final source = File(widget.path); + final size = await source.length(); + // 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( + context, + 'Diese Datei ist zu groß (${(size / (1024 * 1024)).toStringAsFixed(0)} MB), ' + 'um direkt gespeichert zu werden. Nutze stattdessen die Teilen-Funktion.', + title: 'Speichern nicht möglich', + ); + return; + } + final bytes = await source.readAsBytes(); final saved = await FilePicker.saveFile( fileName: widget.path.split('/').last, bytes: bytes, @@ -279,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', ), @@ -440,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( @@ -545,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, @@ -556,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 { @@ -587,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; @@ -780,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 7e8cf4d..5ef5271 100644 --- a/lib/widget/user_avatar.dart +++ b/lib/widget/user_avatar.dart @@ -1,3 +1,4 @@ +import 'dart:collection'; import 'dart:convert'; import 'dart:typed_data'; @@ -29,15 +30,48 @@ class _AvatarPayload { _AvatarPayload(this.bytes, this.isSvg); } -final Map> _avatarCache = {}; +class _AvatarCacheEntry { + final _AvatarPayload? payload; + final DateTime fetchedAt; + _AvatarCacheEntry(this.payload, this.fetchedAt); +} + +// 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); + +// Pending map dedups concurrent mounts onto a single HTTP call. +final LinkedHashMap _resolvedAvatars = + LinkedHashMap(); +final Map> _pendingAvatars = {}; + +_AvatarCacheEntry? _readAvatarCache(String url) { + final entry = _resolvedAvatars.remove(url); + if (entry == null) return null; + if (DateTime.now().difference(entry.fetchedAt) > _kAvatarCacheTtl) { + return null; + } + // Re-insert at the tail so it counts as most-recently-used. + _resolvedAvatars[url] = entry; + return entry; +} + +void _writeAvatarCache(String url, _AvatarPayload? payload) { + _resolvedAvatars.remove(url); + _resolvedAvatars[url] = _AvatarCacheEntry(payload, DateTime.now()); + while (_resolvedAvatars.length > _kAvatarCacheMax) { + _resolvedAvatars.remove(_resolvedAvatars.keys.first); + } +} class _UserAvatarState extends State { - late Future<_AvatarPayload?> _payload; + _AvatarPayload? _payload; @override void initState() { super.initState(); - _payload = _load(); + _attach(); } @override @@ -46,7 +80,7 @@ class _UserAvatarState extends State { if (oldWidget.id != widget.id || oldWidget.isGroup != widget.isGroup || oldWidget.size != widget.size) { - _payload = _load(); + _attach(); } } @@ -58,9 +92,21 @@ class _UserAvatarState extends State { return 'https://$host/avatar/${widget.id}/${widget.size}'; } - Future<_AvatarPayload?> _load() { + void _attach() { final url = _url(); - return _avatarCache.putIfAbsent(url, () => _fetch(url)); + final cached = _readAvatarCache(url); + if (cached != null) { + _payload = cached.payload; + return; + } + _payload = null; + final pending = _pendingAvatars.putIfAbsent(url, () => _fetch(url)); + pending.then((p) { + _writeAvatarCache(url, p); + _pendingAvatars.remove(url); + if (!mounted || _url() != url) return; + setState(() => _payload = p); + }); } Future<_AvatarPayload?> _fetch(String url) async { @@ -97,49 +143,45 @@ class _UserAvatarState extends State { Widget build(BuildContext context) { final radius = widget.size.toDouble(); final theme = Theme.of(context); + final payload = _payload; - return FutureBuilder<_AvatarPayload?>( - future: _payload, - builder: (context, snapshot) { - final payload = snapshot.data; - - Widget content; - if (payload == null) { - content = Icon( - widget.isGroup ? Icons.group : Icons.person, - size: radius, - color: Colors.white, - ); - } else if (payload.isSvg) { - content = SvgPicture.memory( - payload.bytes, - width: radius * 2, - height: radius * 2, - fit: BoxFit.cover, - ); - } else { - content = Image.memory( - payload.bytes, - width: radius * 2, - height: radius * 2, - fit: BoxFit.cover, - gaplessPlayback: true, - ); - } - - return CircleAvatar( - radius: radius, - backgroundColor: theme.primaryColor, - foregroundColor: Colors.white, - child: ClipOval( - child: SizedBox( - width: radius * 2, - height: radius * 2, - child: content, - ), - ), + Widget content; + if (payload != null) { + if (payload.isSvg) { + content = SvgPicture.memory( + payload.bytes, + width: radius * 2, + height: radius * 2, + fit: BoxFit.cover, ); - }, + } else { + content = Image.memory( + payload.bytes, + width: radius * 2, + height: radius * 2, + fit: BoxFit.cover, + gaplessPlayback: true, + ); + } + } else { + content = Icon( + widget.isGroup ? Icons.group : Icons.person, + size: radius, + color: Colors.white, + ); + } + + return CircleAvatar( + radius: radius, + backgroundColor: theme.primaryColor, + foregroundColor: Colors.white, + child: ClipOval( + child: SizedBox( + width: radius * 2, + height: radius * 2, + child: content, + ), + ), ); } } diff --git a/pubspec.yaml b/pubspec.yaml index 660c69b..4077c39 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,11 @@ dependencies: flutter_linkify: ^6.0.0 linkify: ^5.0.0 flutter_local_notifications: ^21.0.0 + # Cancels FCM-rendered notifications by their server-set tag + # (Android NotificationManager.cancel, iOS removeDeliveredNotifications via + # apns-collapse-id). Used to dismiss a chat's notification when the user + # opens or marks the chat read. + eraser: ^3.0.0 scrollable_positioned_list: ^0.3.8 flutter_split_view: ^0.1.2 flutter_svg: ^2.0.10