implemented chat long-polling and optimistic updates, centralized notification management, optimized avatar caching, cleanup and bugfixes #99
@@ -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<GetChatResponse?> 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<String, dynamic>;
|
||||
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>)
|
||||
..headers = response.headers;
|
||||
}
|
||||
throw ServerException(
|
||||
statusCode: status,
|
||||
technicalDetails: 'LongPollChat $uri: HTTP $status',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,5 @@ class SetReadMarker extends TalkApi {
|
||||
Uri uri,
|
||||
Object? body,
|
||||
Map<String, String>? 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);
|
||||
}
|
||||
|
||||
+55
-55
@@ -36,17 +36,33 @@ class App extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
late Timer _refetchChats;
|
||||
late Timer _updateTimings;
|
||||
StreamSubscription<dynamic>? _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<RemoteMessage>? _onMessageSub;
|
||||
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
|
||||
StreamSubscription<String>? _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<ChatListBloc>();
|
||||
bloc.setAutoRefreshInterval(
|
||||
talkIsActive ? _chatListActiveInterval : _chatListIdleInterval,
|
||||
);
|
||||
if (talkIsActive) bloc.refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -65,13 +81,12 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
Future<void> _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<App> 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<App> with WidgetsBindingObserver {
|
||||
if (!mounted) return;
|
||||
context.read<BreakerBloc>().refresh();
|
||||
context.read<ChatListBloc>().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<TimetableBloc>();
|
||||
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<SettingsCubit>();
|
||||
_timetableWidgetSync?.cancel();
|
||||
_timetableWidgetSync = timetable.stream.listen((state) {
|
||||
@@ -123,8 +133,7 @@ class _AppState extends State<App> 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<App> 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<ChatListBloc>().refresh();
|
||||
});
|
||||
});
|
||||
|
||||
UpdateUserIndex.index();
|
||||
|
||||
if (context.read<SettingsCubit>().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<App> 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<App> 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<App> 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<App> 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<App> 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,
|
||||
),
|
||||
),
|
||||
|
||||
+6
-1
@@ -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<void> main() async {
|
||||
),
|
||||
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
|
||||
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
|
||||
BlocProvider<ChatBloc>(create: (_) => ChatBloc()),
|
||||
BlocProvider<ChatBloc>(
|
||||
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
|
||||
),
|
||||
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
|
||||
],
|
||||
child: const Main(),
|
||||
@@ -199,6 +202,8 @@ class _MainState extends State<Main> {
|
||||
checkerboardRasterCacheImages:
|
||||
devToolsSettings.checkerboardRasterCacheImages,
|
||||
debugShowCheckedModeBanner: false,
|
||||
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
|
||||
navigatorObservers: [AppRoutes.chatRouteObserver],
|
||||
localizationsDelegates: const [
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
|
||||
@@ -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<void> 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<ChatBloc>();
|
||||
// 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<void> onAppOpenedByNotification(
|
||||
|
||||
@@ -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<void> 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<ChatListBloc>().refresh();
|
||||
context.read<ChatBloc>().refresh();
|
||||
}
|
||||
|
||||
/// Switches to the Talk tab. If [chatToken] is provided, also schedules
|
||||
|
||||
@@ -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<String?> 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<PageRoute<dynamic>> chatRouteObserver =
|
||||
RouteObserver<PageRoute<dynamic>>();
|
||||
|
||||
static void openFolder(BuildContext context, List<String> 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<ChatListBloc>().markRoomAsRead(room.token, room.lastMessage.id);
|
||||
NotificationTasks.clearNotificationsForChat(room.token);
|
||||
TalkNavigator.pushSplitView(
|
||||
context,
|
||||
ChatView(room: room, selfId: selfId, avatar: avatar),
|
||||
|
||||
@@ -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<ChatEvent, ChatState, ChatRepository> {
|
||||
extends LoadableHydratedBloc<ChatEvent, ChatState, ChatRepository>
|
||||
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<void> 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<ChatState>());
|
||||
_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<ChatState>());
|
||||
_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<void> 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<void>.microtask(() {
|
||||
if (isClosed) return;
|
||||
_loadChat(token).then((_) => _startLongPoll(token));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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 = <int, GetChatResponseObject>{};
|
||||
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';
|
||||
|
||||
@@ -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<ChatListEvent, ChatListState, ChatListRepository> {
|
||||
bool _forceRenew = false;
|
||||
Timer? _autoRefreshTimer;
|
||||
Duration? _autoRefreshInterval;
|
||||
|
||||
@override
|
||||
void retry() {
|
||||
@@ -22,6 +26,25 @@ class ChatListBloc
|
||||
super.retry();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<void> refresh({bool renew = true}) async {
|
||||
add(RefetchStarted<ChatListState>());
|
||||
Future<void> refresh({bool renew = true, bool silent = false}) async {
|
||||
if (!silent) add(RefetchStarted<ChatListState>());
|
||||
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<int>(
|
||||
|
||||
@@ -102,6 +102,7 @@ class MarianumDateRow extends StatelessWidget {
|
||||
initialDescription: event.description,
|
||||
initialStart: event.start,
|
||||
initialEnd: event.end,
|
||||
initialAllDay: event.isAllDay,
|
||||
),
|
||||
barrierDismissible: false,
|
||||
),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<ChatListBloc, LoadableState<ChatListState>>(
|
||||
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(),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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<ChatView> createState() => _ChatViewState();
|
||||
}
|
||||
|
||||
class _ChatViewState extends State<ChatView> {
|
||||
class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||
final TextEditingController _searchTextController = TextEditingController();
|
||||
final Map<int, int> _matchIndices = {};
|
||||
@@ -48,12 +52,73 @@ class _ChatViewState extends State<ChatView> {
|
||||
GetChatResponse? _matchesComputedFor;
|
||||
String? _matchesComputedQuery;
|
||||
|
||||
// Captured in initState because the framework has unmounted us by the
|
||||
// time dispose runs.
|
||||
ChatBloc? _chatBlocRef;
|
||||
ChatListBloc? _chatListBlocRef;
|
||||
PageRoute<dynamic>? _subscribedRoute;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chatBlocRef = context.read<ChatBloc>();
|
||||
_chatListBlocRef = context.read<ChatListBloc>();
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ChatTile> {
|
||||
void _refreshList() => context.read<ChatListBloc>().refresh();
|
||||
|
||||
Future<void> _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<ChatListBloc>().markRoomAsRead(token, lastId);
|
||||
unawaited(NotificationTasks.clearNotificationsForChat(token));
|
||||
await context.read<ChatBloc>().sendServerReadMarker(token, lastId);
|
||||
if (!mounted) return;
|
||||
_refreshList();
|
||||
}
|
||||
@@ -154,18 +152,17 @@ class _ChatTileState extends State<ChatTile> {
|
||||
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<ChatBloc>().setToken(widget.data.token);
|
||||
},
|
||||
onLongPress: () {
|
||||
if (widget.disableContextActions) return;
|
||||
|
||||
@@ -78,34 +78,53 @@ class HighlightedLinkify extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HighlightedLinkifyState extends State<HighlightedLinkify> {
|
||||
final List<TapGestureRecognizer> _recognizers = [];
|
||||
// Cached per link text — search rebuilds keystroke-by-keystroke
|
||||
// would otherwise churn allocate/dispose. Pruned via [_seenLinkKeys].
|
||||
final Map<String, TapGestureRecognizer> _recognizers = {};
|
||||
final Set<String> _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<HighlightedLinkify> {
|
||||
|
||||
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<HighlightedLinkify> {
|
||||
}
|
||||
}
|
||||
|
||||
_pruneUnseen();
|
||||
|
||||
return Text.rich(TextSpan(children: spans));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CustomEventEditDialog> {
|
||||
}
|
||||
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(
|
||||
|
||||
@@ -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<Widget>(
|
||||
(e) => [
|
||||
Text(e.main),
|
||||
if (e.sub != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Text(e.sub!),
|
||||
),
|
||||
],
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
trailing: trailing,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+27
-39
@@ -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<String> _imageExtensions = {
|
||||
'wbmp',
|
||||
};
|
||||
|
||||
/// Video container formats whose playback the platform decoders (ExoPlayer
|
||||
/// on Android, AVPlayer on iOS) handle out of the box.
|
||||
const Set<String> _videoExtensions = {
|
||||
'mp4',
|
||||
'm4v',
|
||||
@@ -67,9 +64,8 @@ const Set<String> _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<String> _audioExtensions = {
|
||||
'mp3',
|
||||
'm4a',
|
||||
@@ -81,9 +77,7 @@ const Set<String> _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<String> _textExtensions = {
|
||||
'txt', 'md', 'markdown', 'rst', 'log',
|
||||
'json', 'json5', 'xml', 'yaml', 'yml', 'toml',
|
||||
@@ -104,10 +98,7 @@ const Set<String> _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<bool> _looksLikeText(String path) async {
|
||||
final file = File(path);
|
||||
RandomAccessFile? raf;
|
||||
@@ -126,10 +117,8 @@ Future<bool> _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<FileViewer> {
|
||||
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<FileViewer> {
|
||||
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<FileViewer> {
|
||||
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<FileViewer> {
|
||||
}
|
||||
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<FileViewer> {
|
||||
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<FileViewer> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
+89
-47
@@ -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<String, Future<_AvatarPayload?>> _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<String, _AvatarCacheEntry> _resolvedAvatars =
|
||||
LinkedHashMap<String, _AvatarCacheEntry>();
|
||||
final Map<String, Future<_AvatarPayload?>> _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<UserAvatar> {
|
||||
late Future<_AvatarPayload?> _payload;
|
||||
_AvatarPayload? _payload;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_payload = _load();
|
||||
_attach();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -46,7 +80,7 @@ class _UserAvatarState extends State<UserAvatar> {
|
||||
if (oldWidget.id != widget.id ||
|
||||
oldWidget.isGroup != widget.isGroup ||
|
||||
oldWidget.size != widget.size) {
|
||||
_payload = _load();
|
||||
_attach();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,9 +92,21 @@ class _UserAvatarState extends State<UserAvatar> {
|
||||
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<UserAvatar> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user