implemented chat long-polling and optimistic updates, centralized notification management, optimized avatar caching
This commit is contained in:
@@ -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,
|
Uri uri,
|
||||||
Object? body,
|
Object? body,
|
||||||
Map<String, String>? headers,
|
Map<String, String>? headers,
|
||||||
) {
|
) => readState ? http.post(uri, headers: headers) : http.delete(uri, headers: headers);
|
||||||
if (readState) {
|
|
||||||
return http.post(uri, headers: headers);
|
|
||||||
} else {
|
|
||||||
return http.delete(uri, headers: headers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-9
@@ -36,7 +36,6 @@ class App extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AppState extends State<App> with WidgetsBindingObserver {
|
class _AppState extends State<App> with WidgetsBindingObserver {
|
||||||
late Timer _refetchChats;
|
|
||||||
late Timer _updateTimings;
|
late Timer _updateTimings;
|
||||||
StreamSubscription<dynamic>? _timetableWidgetSync;
|
StreamSubscription<dynamic>? _timetableWidgetSync;
|
||||||
// Tracked via the bottom-nav controller's listener so it always reflects the
|
// Tracked via the bottom-nav controller's listener so it always reflects the
|
||||||
@@ -45,8 +44,25 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
int _knownTotalTabs = 1;
|
int _knownTotalTabs = 1;
|
||||||
bool _userOnLastTab = false;
|
bool _userOnLastTab = false;
|
||||||
|
|
||||||
|
static const Duration _chatListActiveInterval = Duration(seconds: 15);
|
||||||
|
static const Duration _chatListIdleInterval = Duration(seconds: 60);
|
||||||
|
|
||||||
void _onTabControllerChanged() {
|
void _onTabControllerChanged() {
|
||||||
_userOnLastTab = Main.bottomNavigator.index == _knownTotalTabs - 1;
|
_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
|
@override
|
||||||
@@ -138,19 +154,13 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
ShareIntentListener.instance.attach();
|
ShareIntentListener.instance.attach();
|
||||||
ShareIntentListener.pending.addListener(_handlePendingShare);
|
ShareIntentListener.pending.addListener(_handlePendingShare);
|
||||||
_handlePendingShare();
|
_handlePendingShare();
|
||||||
|
_syncChatListPolling();
|
||||||
});
|
});
|
||||||
|
|
||||||
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
|
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
});
|
});
|
||||||
|
|
||||||
_refetchChats = Timer.periodic(const Duration(seconds: 60), (_) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
context.read<ChatListBloc>().refresh();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
UpdateUserIndex.index();
|
UpdateUserIndex.index();
|
||||||
|
|
||||||
if (context.read<SettingsCubit>().val().notificationSettings.enabled) {
|
if (context.read<SettingsCubit>().val().notificationSettings.enabled) {
|
||||||
@@ -181,7 +191,6 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_refetchChats.cancel();
|
|
||||||
_updateTimings.cancel();
|
_updateTimings.cancel();
|
||||||
_timetableWidgetSync?.cancel();
|
_timetableWidgetSync?.cancel();
|
||||||
ShareIntentListener.pending.removeListener(_handlePendingShare);
|
ShareIntentListener.pending.removeListener(_handlePendingShare);
|
||||||
|
|||||||
+6
-1
@@ -23,6 +23,7 @@ import 'app.dart';
|
|||||||
import 'background/widget_background_task.dart';
|
import 'background/widget_background_task.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
import 'model/account_data.dart';
|
import 'model/account_data.dart';
|
||||||
|
import 'routing/app_routes.dart';
|
||||||
import 'share_intent/share_intent_listener.dart';
|
import 'share_intent/share_intent_listener.dart';
|
||||||
import 'state/app/modules/account/bloc/account_bloc.dart';
|
import 'state/app/modules/account/bloc/account_bloc.dart';
|
||||||
import 'state/app/modules/account/bloc/account_state.dart';
|
import 'state/app/modules/account/bloc/account_state.dart';
|
||||||
@@ -153,7 +154,9 @@ Future<void> main() async {
|
|||||||
),
|
),
|
||||||
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
|
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
|
||||||
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
|
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
|
||||||
BlocProvider<ChatBloc>(create: (_) => ChatBloc()),
|
BlocProvider<ChatBloc>(
|
||||||
|
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
|
||||||
|
),
|
||||||
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
|
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
|
||||||
],
|
],
|
||||||
child: const Main(),
|
child: const Main(),
|
||||||
@@ -199,6 +202,8 @@ class _MainState extends State<Main> {
|
|||||||
checkerboardRasterCacheImages:
|
checkerboardRasterCacheImages:
|
||||||
devToolsSettings.checkerboardRasterCacheImages,
|
devToolsSettings.checkerboardRasterCacheImages,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
|
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
|
||||||
|
navigatorObservers: [AppRoutes.chatRouteObserver],
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [
|
||||||
...GlobalMaterialLocalizations.delegates,
|
...GlobalMaterialLocalizations.delegates,
|
||||||
GlobalWidgetsLocalizations.delegate,
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/material.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/debug_tile.dart';
|
||||||
import '../widget/debug/json_viewer.dart';
|
import '../widget/debug/json_viewer.dart';
|
||||||
import '../widget/info_dialog.dart';
|
import '../widget/info_dialog.dart';
|
||||||
import 'notification_tasks.dart';
|
import 'notification_tasks.dart';
|
||||||
|
|
||||||
|
// `vm:entry-point` on the class so AOT tree-shaking doesn't drop it: the
|
||||||
|
// FCM background handler runs in a fresh isolate that looks up the class
|
||||||
|
// by name from native code.
|
||||||
|
@pragma('vm:entry-point')
|
||||||
class NotificationController {
|
class NotificationController {
|
||||||
// Notification display is handled by the Firebase SDK using server-generated payloads.
|
// Notification display is handled by the Firebase SDK using server-generated payloads.
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
@@ -17,8 +25,21 @@ class NotificationController {
|
|||||||
RemoteMessage message,
|
RemoteMessage message,
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
) async {
|
) async {
|
||||||
NotificationTasks.updateProviders(context);
|
final pushToken = _extractChatToken(message);
|
||||||
|
final activeToken = context.read<ChatBloc>().state.data?.currentToken ?? '';
|
||||||
|
final chatIsOpen =
|
||||||
|
pushToken != null && pushToken.isNotEmpty && pushToken == activeToken;
|
||||||
|
|
||||||
NotificationTasks.updateBadgeCount(message);
|
NotificationTasks.updateBadgeCount(message);
|
||||||
|
|
||||||
|
if (chatIsOpen) {
|
||||||
|
// Long-poll already fetches the message and moves the marker; just
|
||||||
|
// dismiss any tray entry that slipped through foreground rendering.
|
||||||
|
unawaited(NotificationTasks.clearNotificationsForChat(pushToken));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationTasks.updateProviders(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> onAppOpenedByNotification(
|
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:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../routing/app_routes.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 '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
|
import 'notification_service.dart';
|
||||||
|
|
||||||
class NotificationTasks {
|
class NotificationTasks {
|
||||||
static void updateBadgeCount(RemoteMessage notification) {
|
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) {
|
static void updateProviders(BuildContext context) {
|
||||||
context.read<ChatListBloc>().refresh();
|
context.read<ChatListBloc>().refresh();
|
||||||
context.read<ChatBloc>().refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switches to the Talk tab. If [chatToken] is provided, also schedules
|
/// 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 '../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
import '../model/account_data.dart';
|
import '../model/account_data.dart';
|
||||||
|
import '../notification/notification_tasks.dart';
|
||||||
import '../share_intent/pending_share.dart';
|
import '../share_intent/pending_share.dart';
|
||||||
import '../share_intent/remote_file_ref.dart';
|
import '../share_intent/remote_file_ref.dart';
|
||||||
import '../state/app/modules/app_modules.dart';
|
import '../state/app/modules/app_modules.dart';
|
||||||
@@ -39,6 +40,11 @@ class AppRoutes {
|
|||||||
/// by `ChatList` once the matching room is loaded.
|
/// by `ChatList` once the matching room is loaded.
|
||||||
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null);
|
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) {
|
static void openFolder(BuildContext context, List<String> path) {
|
||||||
pushScreen(context, withNavBar: false, screen: Files(path: path));
|
pushScreen(context, withNavBar: false, screen: Files(path: path));
|
||||||
}
|
}
|
||||||
@@ -177,6 +183,12 @@ class AppRoutes {
|
|||||||
required UserAvatar avatar,
|
required UserAvatar avatar,
|
||||||
bool overrideToSingleSubScreen = true,
|
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(
|
TalkNavigator.pushSplitView(
|
||||||
context,
|
context,
|
||||||
ChatView(room: room, selfId: selfId, avatar: avatar),
|
ChatView(room: room, selfId: selfId, avatar: avatar),
|
||||||
|
|||||||
@@ -1,15 +1,54 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import '../../../../../api/errors/error_mapper.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/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.dart';
|
||||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.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 '../repository/chat_repository.dart';
|
||||||
import 'chat_event.dart';
|
import 'chat_event.dart';
|
||||||
import 'chat_state.dart';
|
import 'chat_state.dart';
|
||||||
|
|
||||||
class ChatBloc
|
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;
|
||||||
|
|
||||||
|
/// Distinguishes "the bloc tracks a chat the user has open" from "the
|
||||||
|
/// bloc remembers the last opened chat". App-resume only refreshes when
|
||||||
|
/// true — otherwise we'd silently mark a long-since-left chat as read
|
||||||
|
/// on the server. Can't reuse `currentToken` for this signal because
|
||||||
|
/// clearing it on leaveChat raced with setToken-from-didPopNext when
|
||||||
|
/// popping a stacked chat.
|
||||||
|
bool _chatViewActive = false;
|
||||||
|
|
||||||
DateTime _lastTokenSet = DateTime.fromMillisecondsSinceEpoch(0);
|
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
|
@override
|
||||||
ChatRepository repository() => ChatRepository();
|
ChatRepository repository() => ChatRepository();
|
||||||
|
|
||||||
@@ -33,24 +72,74 @@ class ChatBloc
|
|||||||
}
|
}
|
||||||
|
|
||||||
void setToken(String token) {
|
void setToken(String token) {
|
||||||
|
_chatViewActive = true;
|
||||||
if (token == (innerState?.currentToken ?? '')) {
|
if (token == (innerState?.currentToken ?? '')) {
|
||||||
refresh();
|
refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_stopLongPoll();
|
||||||
add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null)));
|
add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null)));
|
||||||
add(RefetchStarted<ChatState>());
|
add(RefetchStarted<ChatState>());
|
||||||
_loadChat(token);
|
_scheduleLoad(token);
|
||||||
}
|
|
||||||
|
|
||||||
void setReferenceMessageId(int? messageId) {
|
|
||||||
add(Emit((s) => s.copyWith(referenceMessageId: messageId)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void refresh() {
|
void refresh() {
|
||||||
final token = innerState?.currentToken ?? '';
|
final token = innerState?.currentToken ?? '';
|
||||||
if (token.isEmpty) return;
|
if (token.isEmpty) return;
|
||||||
add(RefetchStarted<ChatState>());
|
add(RefetchStarted<ChatState>());
|
||||||
_loadChat(token);
|
_scheduleLoad(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setReferenceMessageId(int? messageId) {
|
||||||
|
add(Emit((s) => s.copyWith(referenceMessageId: messageId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token-aware: only acts when the bloc still points at [fromToken].
|
||||||
|
/// When popping a stacked chat (notification opened B over A), A's
|
||||||
|
/// didPopNext has already run setToken(A) by the time B's dispose
|
||||||
|
/// fires — at that point currentToken is A and we must leave it alone.
|
||||||
|
void leaveChat(String fromToken) {
|
||||||
|
if ((innerState?.currentToken ?? '') != fromToken) return;
|
||||||
|
_chatViewActive = false;
|
||||||
|
_stopLongPoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fire-and-forget server-side read-marker. Exposed so view-side
|
||||||
|
/// callers (long-press menu, ChatView dispose) hit the same path.
|
||||||
|
Future<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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Defer _loadChat by one microtask so the Bloc worker processes the
|
||||||
|
/// preceding Emit/RefetchStarted before any cache/network callback
|
||||||
|
/// fires — otherwise a quick cache hit can run with the previous
|
||||||
|
/// token in state, fail stillCurrent(), and never emit a DataGathered.
|
||||||
|
void _scheduleLoad(String token) {
|
||||||
|
Future<void>.microtask(() {
|
||||||
|
if (isClosed) return;
|
||||||
|
_loadChat(token).then((_) => _startLongPoll(token));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadChat(String token) async {
|
Future<void> _loadChat(String token) async {
|
||||||
@@ -69,14 +158,21 @@ class ChatBloc
|
|||||||
token: token,
|
token: token,
|
||||||
onCacheData: (data) {
|
onCacheData: (data) {
|
||||||
if (!stillCurrent()) return;
|
if (!stillCurrent()) return;
|
||||||
// Cache hit: show data immediately but preserve lastFetch — the
|
// Only paint cache when the state is empty — restoring a stale
|
||||||
// cached payload may be stale and we don't want the UI to claim a
|
// disk snapshot over already-merged long-poll data would visibly
|
||||||
// fresh fetch just happened.
|
// drop those messages until the network call resolves.
|
||||||
|
if (innerState?.chatResponse != null) return;
|
||||||
add(Emit((s) => s.copyWith(chatResponse: data)));
|
add(Emit((s) => s.copyWith(chatResponse: data)));
|
||||||
},
|
},
|
||||||
onNetworkData: (data) {
|
onNetworkData: (data) {
|
||||||
|
// Server-side mark runs unconditionally with the freshly-fetched
|
||||||
|
// maxId. Skipping it on stillCurrent==false would leave the
|
||||||
|
// server cursor wherever a quick navigation away left it.
|
||||||
|
final maxId = _maxMessageId(data);
|
||||||
|
if (maxId > 0) unawaited(sendServerReadMarker(token, maxId));
|
||||||
if (!stillCurrent()) return;
|
if (!stillCurrent()) return;
|
||||||
add(DataGathered((s) => s.copyWith(chatResponse: data)));
|
_applyChatResponse(data);
|
||||||
|
if (maxId > 0) _chatListBloc?.markRoomAsRead(token, maxId);
|
||||||
},
|
},
|
||||||
onError: (e) => capturedError = e,
|
onError: (e) => capturedError = e,
|
||||||
);
|
);
|
||||||
@@ -98,4 +194,115 @@ class ChatBloc
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Long-poll loop
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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 already 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges [incoming] into the existing chatResponse and emits as a
|
||||||
|
/// fresh fetch. Dedups by id (newer wins, so server edits/deletes
|
||||||
|
/// propagate). Shared by initial-load and long-poll so neither wipes
|
||||||
|
/// messages the other already committed.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highest-id message worth showing as the room preview — comments
|
||||||
|
/// and voice messages, matching what the server picks for `lastMessage`.
|
||||||
|
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 'dart:developer';
|
||||||
|
|
||||||
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
||||||
|
|
||||||
import '../../../../../api/errors/error_mapper.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 '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../../../infrastructure/loadable_state/loading_error.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.dart';
|
||||||
@@ -15,6 +17,8 @@ class ChatListBloc
|
|||||||
extends
|
extends
|
||||||
LoadableHydratedBloc<ChatListEvent, ChatListState, ChatListRepository> {
|
LoadableHydratedBloc<ChatListEvent, ChatListState, ChatListRepository> {
|
||||||
bool _forceRenew = false;
|
bool _forceRenew = false;
|
||||||
|
Timer? _autoRefreshTimer;
|
||||||
|
Duration? _autoRefreshInterval;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void retry() {
|
void retry() {
|
||||||
@@ -22,6 +26,27 @@ class ChatListBloc
|
|||||||
super.retry();
|
super.retry();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
_autoRefreshTimer?.cancel();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets (or clears) the recurring background refresh. Silent so the
|
||||||
|
/// loading bar doesn't blink several times a minute; pull-to-refresh
|
||||||
|
/// and tab-activation refreshes are non-silent for explicit feedback.
|
||||||
|
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
|
@override
|
||||||
ChatListRepository repository() => ChatListRepository();
|
ChatListRepository repository() => ChatListRepository();
|
||||||
|
|
||||||
@@ -51,8 +76,8 @@ class ChatListBloc
|
|||||||
if (capturedError != null) throw capturedError!;
|
if (capturedError != null) throw capturedError!;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refresh({bool renew = true}) async {
|
Future<void> refresh({bool renew = true, bool silent = false}) async {
|
||||||
add(RefetchStarted<ChatListState>());
|
if (!silent) add(RefetchStarted<ChatListState>());
|
||||||
Object? capturedError;
|
Object? capturedError;
|
||||||
try {
|
try {
|
||||||
final rooms = await repo.data.getRooms(
|
final rooms = await repo.data.getRooms(
|
||||||
@@ -82,6 +107,64 @@ class ChatListBloc
|
|||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Optimistically clears the unread counter for [token] so the tile
|
||||||
|
/// reacts before a refresh roundtrip lands. Server-side mark-as-read
|
||||||
|
/// is the caller's job (see [ChatBloc.sendServerReadMarker]).
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes a freshly-received message into the matching room tile so the
|
||||||
|
/// list shows the right preview text + activity timestamp before the
|
||||||
|
/// next full refresh lands. Also clears unread because the long-poll
|
||||||
|
/// only feeds this in for an actively-open chat.
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutates the room with [token] in-place via [mutator] (returning
|
||||||
|
/// true if anything changed) and re-emits if so. Builds a fresh
|
||||||
|
/// [GetRoomResponse] so equality-by-identity in the bloc state
|
||||||
|
/// recognises the change and rebuilds.
|
||||||
|
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) {
|
void _updateAppBadge(GetRoomResponse rooms) {
|
||||||
try {
|
try {
|
||||||
final unread = rooms.data.fold<int>(
|
final unread = rooms.data.fold<int>(
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import '../../../notification/notify_updater.dart';
|
|||||||
import '../../../routing/app_routes.dart';
|
import '../../../routing/app_routes.dart';
|
||||||
import '../../../state/app/infrastructure/loadable_state/loadable_state.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/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_bloc.dart';
|
||||||
import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart';
|
import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart';
|
||||||
import '../../../state/app/modules/settings/bloc/settings_cubit.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/chat_tile.dart';
|
||||||
import 'widgets/split_view_placeholder.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 {
|
class ChatList extends StatelessWidget {
|
||||||
const ChatList({super.key});
|
const ChatList({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) =>
|
Widget build(BuildContext context) => const _ChatListView();
|
||||||
BlocModule<ChatListBloc, LoadableState<ChatListState>>(
|
|
||||||
create: (_) => ChatListBloc(),
|
|
||||||
child: (context, bloc, _) => const _ChatListView(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ChatListView extends StatefulWidget {
|
class _ChatListView extends StatefulWidget {
|
||||||
@@ -65,14 +62,6 @@ class _ChatListViewState extends State<_ChatListView> {
|
|||||||
final resolved = AppRoutes.resolvePendingChat(context);
|
final resolved = AppRoutes.resolvePendingChat(context);
|
||||||
if (resolved == null) return;
|
if (resolved == null) return;
|
||||||
AppRoutes.pendingChatToken.value = null;
|
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(
|
AppRoutes.openChatView(
|
||||||
context,
|
context,
|
||||||
room: resolved.room,
|
room: resolved.room,
|
||||||
@@ -193,7 +182,14 @@ class _ChatListViewState extends State<_ChatListView> {
|
|||||||
.talkSettings
|
.talkSettings
|
||||||
.drafts
|
.drafts
|
||||||
.containsKey(room.token);
|
.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(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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/chat/get_chat_response.dart';
|
||||||
import '../../../api/marianumcloud/talk/room/get_room_response.dart';
|
import '../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../../../extensions/date_time.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/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||||
import '../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
import '../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
import '../../../state/app/modules/chat/bloc/chat_state.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 '../../../theming/app_theme.dart';
|
||||||
import '../../../widget/clickable_app_bar.dart';
|
import '../../../widget/clickable_app_bar.dart';
|
||||||
import '../../../widget/user_avatar.dart';
|
import '../../../widget/user_avatar.dart';
|
||||||
@@ -36,7 +40,7 @@ class ChatView extends StatefulWidget {
|
|||||||
State<ChatView> createState() => _ChatViewState();
|
State<ChatView> createState() => _ChatViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ChatViewState extends State<ChatView> {
|
class _ChatViewState extends State<ChatView> with RouteAware {
|
||||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||||
final TextEditingController _searchTextController = TextEditingController();
|
final TextEditingController _searchTextController = TextEditingController();
|
||||||
final Map<int, int> _matchIndices = {};
|
final Map<int, int> _matchIndices = {};
|
||||||
@@ -48,12 +52,73 @@ class _ChatViewState extends State<ChatView> {
|
|||||||
GetChatResponse? _matchesComputedFor;
|
GetChatResponse? _matchesComputedFor;
|
||||||
String? _matchesComputedQuery;
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
if (_subscribedRoute != null) {
|
||||||
|
AppRoutes.chatRouteObserver.unsubscribe(this);
|
||||||
|
}
|
||||||
|
_markAsReadFinal();
|
||||||
|
_chatBlocRef?.leaveChat(widget.room.token);
|
||||||
_searchTextController.dispose();
|
_searchTextController.dispose();
|
||||||
super.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
|
@override
|
||||||
void didUpdateWidget(covariant ChatView oldWidget) {
|
void didUpdateWidget(covariant ChatView oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|||||||
@@ -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/chat/rich_object_string_processor.dart';
|
||||||
import '../../../../api/marianumcloud/talk/room/get_room_response.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.dart';
|
||||||
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart';
|
|
||||||
import '../../../../extensions/date_time.dart';
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../../model/account_data.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/bloc/chat_bloc.dart';
|
||||||
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
import '../../../../widget/async_action_button.dart';
|
import '../../../../widget/async_action_button.dart';
|
||||||
@@ -17,7 +18,6 @@ import '../../../../widget/confirm_dialog.dart';
|
|||||||
import '../../../../widget/debug/debug_tile.dart';
|
import '../../../../widget/debug/debug_tile.dart';
|
||||||
import '../../../../widget/details_bottom_sheet.dart';
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
import '../../../../widget/user_avatar.dart';
|
import '../../../../widget/user_avatar.dart';
|
||||||
import '../chat_view.dart';
|
|
||||||
import '../talk_navigator.dart';
|
import '../talk_navigator.dart';
|
||||||
|
|
||||||
class ChatTile extends StatefulWidget {
|
class ChatTile extends StatefulWidget {
|
||||||
@@ -61,13 +61,11 @@ class _ChatTileState extends State<ChatTile> {
|
|||||||
void _refreshList() => context.read<ChatListBloc>().refresh();
|
void _refreshList() => context.read<ChatListBloc>().refresh();
|
||||||
|
|
||||||
Future<void> _setCurrentAsRead() async {
|
Future<void> _setCurrentAsRead() async {
|
||||||
await SetReadMarker(
|
final token = widget.data.token;
|
||||||
widget.data.token,
|
final lastId = widget.data.lastMessage.id;
|
||||||
true,
|
context.read<ChatListBloc>().markRoomAsRead(token, lastId);
|
||||||
setReadMarkerParams: SetReadMarkerParams(
|
unawaited(NotificationTasks.clearNotificationsForChat(token));
|
||||||
lastReadMessage: widget.data.lastMessage.id,
|
await context.read<ChatBloc>().sendServerReadMarker(token, lastId);
|
||||||
),
|
|
||||||
).run();
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_refreshList();
|
_refreshList();
|
||||||
}
|
}
|
||||||
@@ -154,18 +152,17 @@ class _ChatTileState extends State<ChatTile> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selfUsername == null) return;
|
if (selfUsername == null) return;
|
||||||
unawaited(_setCurrentAsRead());
|
// openChatView is the single entry point for opening a chat —
|
||||||
final view = ChatView(
|
// 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,
|
room: widget.data,
|
||||||
selfId: selfUsername!,
|
selfId: selfUsername!,
|
||||||
avatar: circleAvatar,
|
avatar: circleAvatar,
|
||||||
);
|
|
||||||
TalkNavigator.pushSplitView(
|
|
||||||
context,
|
|
||||||
view,
|
|
||||||
overrideToSingleSubScreen: true,
|
overrideToSingleSubScreen: true,
|
||||||
);
|
);
|
||||||
context.read<ChatBloc>().setToken(widget.data.token);
|
|
||||||
},
|
},
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
if (widget.disableContextActions) return;
|
if (widget.disableContextActions) return;
|
||||||
|
|||||||
@@ -37,40 +37,10 @@ class AppointmentTile extends StatelessWidget {
|
|||||||
borderRadius: _radius,
|
borderRadius: _radius,
|
||||||
color: color,
|
color: color,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: _TileContent(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
title: appointment.subject,
|
||||||
mainAxisSize: MainAxisSize.max,
|
description: description,
|
||||||
children: [
|
isCustom: isCustom,
|
||||||
_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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -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
|
/// Renders the appointment title. Scales down to fit the available width via
|
||||||
/// [FittedBox], but never below [minFontSize] — when even the minimum size
|
/// [FittedBox], but never below [minFontSize] — when even the minimum size
|
||||||
/// overflows, the text is rendered at [minFontSize] with an ellipsis.
|
/// overflows, the text is rendered at [minFontSize] with an ellipsis.
|
||||||
|
|||||||
+57
-47
@@ -29,15 +29,18 @@ class _AvatarPayload {
|
|||||||
_AvatarPayload(this.bytes, this.isSvg);
|
_AvatarPayload(this.bytes, this.isSvg);
|
||||||
}
|
}
|
||||||
|
|
||||||
final Map<String, Future<_AvatarPayload?>> _avatarCache = {};
|
// Resolved payloads are cached so re-mounts render synchronously; in-flight
|
||||||
|
// requests are deduped so concurrent mounts share one HTTP call.
|
||||||
|
final Map<String, _AvatarPayload?> _resolvedAvatars = {};
|
||||||
|
final Map<String, Future<_AvatarPayload?>> _pendingAvatars = {};
|
||||||
|
|
||||||
class _UserAvatarState extends State<UserAvatar> {
|
class _UserAvatarState extends State<UserAvatar> {
|
||||||
late Future<_AvatarPayload?> _payload;
|
_AvatarPayload? _payload;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_payload = _load();
|
_attach();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -46,7 +49,7 @@ class _UserAvatarState extends State<UserAvatar> {
|
|||||||
if (oldWidget.id != widget.id ||
|
if (oldWidget.id != widget.id ||
|
||||||
oldWidget.isGroup != widget.isGroup ||
|
oldWidget.isGroup != widget.isGroup ||
|
||||||
oldWidget.size != widget.size) {
|
oldWidget.size != widget.size) {
|
||||||
_payload = _load();
|
_attach();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,9 +61,20 @@ class _UserAvatarState extends State<UserAvatar> {
|
|||||||
return 'https://$host/avatar/${widget.id}/${widget.size}';
|
return 'https://$host/avatar/${widget.id}/${widget.size}';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<_AvatarPayload?> _load() {
|
void _attach() {
|
||||||
final url = _url();
|
final url = _url();
|
||||||
return _avatarCache.putIfAbsent(url, () => _fetch(url));
|
if (_resolvedAvatars.containsKey(url)) {
|
||||||
|
_payload = _resolvedAvatars[url];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_payload = null;
|
||||||
|
final pending = _pendingAvatars.putIfAbsent(url, () => _fetch(url));
|
||||||
|
pending.then((p) {
|
||||||
|
_resolvedAvatars[url] = p;
|
||||||
|
_pendingAvatars.remove(url);
|
||||||
|
if (!mounted || _url() != url) return;
|
||||||
|
setState(() => _payload = p);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<_AvatarPayload?> _fetch(String url) async {
|
Future<_AvatarPayload?> _fetch(String url) async {
|
||||||
@@ -97,49 +111,45 @@ class _UserAvatarState extends State<UserAvatar> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final radius = widget.size.toDouble();
|
final radius = widget.size.toDouble();
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final payload = _payload;
|
||||||
|
|
||||||
return FutureBuilder<_AvatarPayload?>(
|
Widget content;
|
||||||
future: _payload,
|
if (payload != null) {
|
||||||
builder: (context, snapshot) {
|
if (payload.isSvg) {
|
||||||
final payload = snapshot.data;
|
content = SvgPicture.memory(
|
||||||
|
payload.bytes,
|
||||||
Widget content;
|
width: radius * 2,
|
||||||
if (payload == null) {
|
height: radius * 2,
|
||||||
content = Icon(
|
fit: BoxFit.cover,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
} 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
|
flutter_linkify: ^6.0.0
|
||||||
linkify: ^5.0.0
|
linkify: ^5.0.0
|
||||||
flutter_local_notifications: ^21.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
|
scrollable_positioned_list: ^0.3.8
|
||||||
flutter_split_view: ^0.1.2
|
flutter_split_view: ^0.1.2
|
||||||
flutter_svg: ^2.0.10
|
flutter_svg: ^2.0.10
|
||||||
|
|||||||
Reference in New Issue
Block a user