453 lines
15 KiB
Dart
453 lines
15 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
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 '../../../widget/chat_background.dart';
|
|
import '../../../widget/clickable_app_bar.dart';
|
|
import '../../../widget/user_avatar.dart';
|
|
import 'data/chat_message_matcher.dart';
|
|
import 'details/chat_info.dart';
|
|
import 'search/chat_search_controller.dart';
|
|
import 'search/chat_search_delegate.dart';
|
|
import 'talk_navigator.dart';
|
|
import 'widgets/chat_bubble.dart';
|
|
import 'widgets/chat_textfield.dart';
|
|
import 'widgets/history_jump_banner.dart';
|
|
|
|
class ChatView extends StatefulWidget {
|
|
final GetRoomResponseObject room;
|
|
final String selfId;
|
|
final UserAvatar avatar;
|
|
|
|
const ChatView({
|
|
super.key,
|
|
required this.room,
|
|
required this.selfId,
|
|
required this.avatar,
|
|
});
|
|
|
|
@override
|
|
State<ChatView> createState() => _ChatViewState();
|
|
}
|
|
|
|
class _ChatViewState extends State<ChatView> with RouteAware {
|
|
final ItemScrollController _itemScrollController = ItemScrollController();
|
|
final ItemPositionsListener _positionsListener =
|
|
ItemPositionsListener.create();
|
|
|
|
// Number of rows currently rendered; kept in sync in build so the scroll
|
|
// listener can tell when the oldest row (highest index, reverse list) nears.
|
|
int _itemCount = 0;
|
|
|
|
// Search hit the chat jumped to; stays marked until the next search.
|
|
int? _highlightId;
|
|
String? _highlightQuery;
|
|
int? _highlightIndex;
|
|
|
|
// Running jump towards a search hit that is not loaded yet.
|
|
ChatSearchHit? _jumpTarget;
|
|
DateTime _jumpStartedAt = DateTime.now();
|
|
DateTime? _jumpLoadedUntil;
|
|
int _jumpEpoch = 0;
|
|
|
|
// Captured in initState because the framework has unmounted us by the
|
|
// time dispose runs.
|
|
late final ChatBloc _chatBloc;
|
|
late final ChatListBloc _chatListBloc;
|
|
PageRoute<dynamic>? _subscribedRoute;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_chatBloc = context.read<ChatBloc>();
|
|
_chatListBloc = context.read<ChatListBloc>();
|
|
_positionsListener.itemPositions.addListener(_onScrollPositions);
|
|
NotificationTasks.clearNotificationsForChat(widget.room.token);
|
|
}
|
|
|
|
/// Loads the next older block once the top of the list comes into view.
|
|
void _onScrollPositions() {
|
|
final positions = _positionsListener.itemPositions.value;
|
|
if (positions.isEmpty) return;
|
|
// reverse:true → the highest index is the oldest message (top of screen).
|
|
// Prefetch ~a screenful ahead so the next block is usually already merged
|
|
// before the user reaches the top — the load stays invisible.
|
|
final maxIndex = positions.map((p) => p.index).reduce(math.max);
|
|
if (maxIndex < _itemCount - _kLoadOlderPrefetchRows) return;
|
|
_chatBloc.loadOlder();
|
|
}
|
|
|
|
@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.
|
|
_chatBloc.setToken(widget.room.token);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
if (_subscribedRoute != null) {
|
|
AppRoutes.chatRouteObserver.unsubscribe(this);
|
|
}
|
|
_positionsListener.itemPositions.removeListener(_onScrollPositions);
|
|
_markAsReadFinal();
|
|
_chatBloc.leaveChat(widget.room.token);
|
|
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 = _chatBloc.state.data;
|
|
if (state == null) return;
|
|
if (state.currentToken != widget.room.token) return;
|
|
final response = state.chatResponse;
|
|
if (response == null) return;
|
|
final maxId = response.data.map((m) => m.id).fold<int>(0, math.max);
|
|
if (maxId == 0) return;
|
|
_chatListBloc.markRoomAsRead(widget.room.token, maxId);
|
|
unawaited(_chatBloc.sendServerReadMarker(widget.room.token, maxId));
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant ChatView oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (widget.room.token != oldWidget.room.token) _clearHighlight();
|
|
}
|
|
|
|
void _refetch() => _chatBloc.setToken(widget.room.token);
|
|
|
|
// The built rows only depend on the chat data and the search state; the list
|
|
// itself rebuilds far more often (loading flips, keyboard, parent rebuilds),
|
|
// and re-sorting plus re-creating every bubble each time is wasted work.
|
|
// The top marker is cached separately so a loading-older flip doesn't
|
|
// rebuild every message row.
|
|
Object? _messagesKey;
|
|
List<Widget> _messages = const [];
|
|
Object? _itemsKey;
|
|
List<Widget> _items = const [];
|
|
|
|
List<Widget> _itemsFor(ChatState state) {
|
|
final messagesKey = (
|
|
state.chatResponse,
|
|
_highlightId,
|
|
_highlightQuery,
|
|
widget.room,
|
|
);
|
|
if (messagesKey != _messagesKey) {
|
|
_messagesKey = messagesKey;
|
|
_messages = _buildMessages(state.chatResponse!).reversed.toList();
|
|
}
|
|
final key = (_messages, state.isLoadingOlder, state.hasMoreOld);
|
|
if (key == _itemsKey) return _items;
|
|
_itemsKey = key;
|
|
final items = [..._messages];
|
|
// reverse:true renders index 0 at the bottom, so the top marker
|
|
// (spinner / start-of-chat) goes at the end.
|
|
if (state.isLoadingOlder) {
|
|
items.add(const _LoadingOlderIndicator());
|
|
} else if (!state.hasMoreOld) {
|
|
items.add(
|
|
ChatBubble(
|
|
key: const ValueKey('chat-start'),
|
|
isSender: false,
|
|
bubbleData: GetChatResponseObject.getTextDummy('Anfang des Chats'),
|
|
chatData: widget.room,
|
|
refetch: _refetch,
|
|
),
|
|
);
|
|
}
|
|
return _items = items;
|
|
}
|
|
|
|
Future<void> _openSearch() async {
|
|
final delegate = ChatSearchDelegate(
|
|
ChatSearchController(
|
|
chatToken: widget.room.token,
|
|
loadedMessages: () {
|
|
final state = _chatBloc.state.data;
|
|
return state?.currentToken == widget.room.token
|
|
? state?.chatResponse
|
|
: null;
|
|
},
|
|
),
|
|
);
|
|
final hit = await showSearch<ChatSearchHit?>(
|
|
context: context,
|
|
delegate: delegate,
|
|
);
|
|
final query = delegate.query;
|
|
delegate.disposeController();
|
|
if (hit == null || !mounted) return;
|
|
await _jumpToMessage(hit, query);
|
|
}
|
|
|
|
/// Older hits from the server search are usually not loaded yet; the
|
|
/// history is paged in until the message is reached.
|
|
Future<void> _jumpToMessage(ChatSearchHit hit, String query) async {
|
|
final epoch = ++_jumpEpoch;
|
|
_clearHighlight();
|
|
setState(() {
|
|
_jumpTarget = hit;
|
|
_jumpStartedAt = DateTime.now();
|
|
_jumpLoadedUntil = null;
|
|
});
|
|
final found = await _chatBloc.loadOlderUntil(
|
|
hit.messageId,
|
|
isCancelled: () => !mounted || epoch != _jumpEpoch,
|
|
onProgress: (oldest) {
|
|
if (mounted && epoch == _jumpEpoch) {
|
|
setState(() => _jumpLoadedUntil = oldest);
|
|
}
|
|
},
|
|
);
|
|
if (!mounted || epoch != _jumpEpoch) return;
|
|
setState(() {
|
|
_jumpTarget = null;
|
|
if (found) {
|
|
_highlightId = hit.messageId;
|
|
_highlightQuery = query;
|
|
}
|
|
});
|
|
if (!found) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Nachricht nicht gefunden.')),
|
|
);
|
|
return;
|
|
}
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
final idx = _highlightIndex;
|
|
if (!mounted || idx == null || !_itemScrollController.isAttached) return;
|
|
_itemScrollController.scrollTo(
|
|
index: idx,
|
|
alignment: 0.4,
|
|
duration: const Duration(milliseconds: 350),
|
|
curve: Curves.easeInOut,
|
|
);
|
|
});
|
|
}
|
|
|
|
void _cancelJump() {
|
|
_jumpEpoch++;
|
|
setState(() => _jumpTarget = null);
|
|
}
|
|
|
|
void _clearHighlight() {
|
|
_highlightId = null;
|
|
_highlightQuery = null;
|
|
}
|
|
|
|
List<Widget> _buildMessages(GetChatResponse response) {
|
|
final commonRead = int.parse(
|
|
response.headers?['x-chat-last-common-read'] ?? '0',
|
|
);
|
|
|
|
final messages = <Widget>[];
|
|
int? highlightPosition;
|
|
var lastDate = DateTime.now();
|
|
for (final element in response.sortByTimestamp()) {
|
|
if (ChatMessageMatcher.isHiddenSystemMessage(element)) continue;
|
|
|
|
final elementDate = element.sentAt;
|
|
|
|
if (!elementDate.isSameDay(lastDate)) {
|
|
lastDate = elementDate;
|
|
messages.add(
|
|
ChatBubble(
|
|
isSender: false,
|
|
key: ValueKey(
|
|
'day-${elementDate.year}-${elementDate.month}-'
|
|
'${elementDate.day}',
|
|
),
|
|
bubbleData: GetChatResponseObject.getDateDummy(element.timestamp),
|
|
chatData: widget.room,
|
|
refetch: _refetch,
|
|
),
|
|
);
|
|
}
|
|
|
|
final isHighlighted = element.id == _highlightId;
|
|
if (isHighlighted) highlightPosition = messages.length;
|
|
|
|
messages.add(
|
|
ChatBubble(
|
|
key: ValueKey(element.id),
|
|
isSender:
|
|
element.actorId == widget.selfId &&
|
|
(element.messageType ==
|
|
GetRoomResponseObjectMessageType.comment ||
|
|
element.messageType ==
|
|
GetRoomResponseObjectMessageType.deletedComment),
|
|
bubbleData: element,
|
|
chatData: widget.room,
|
|
refetch: _refetch,
|
|
isRead: element.id <= commonRead,
|
|
selfId: widget.selfId,
|
|
highlightQuery: isHighlighted ? _highlightQuery : null,
|
|
matchHighlight: isHighlighted
|
|
? SearchHighlight.active
|
|
: SearchHighlight.none,
|
|
),
|
|
);
|
|
}
|
|
|
|
// The list is rendered reversed (newest at index 0).
|
|
_highlightIndex = highlightPosition == null
|
|
? null
|
|
: messages.length - 1 - highlightPosition;
|
|
|
|
return messages;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final input = ChatTextfield(
|
|
widget.room.token,
|
|
selfId: widget.selfId,
|
|
roomType: widget.room.type,
|
|
);
|
|
return _KeyboardDismissPopScope(
|
|
child: Scaffold(
|
|
backgroundColor: const Color(0xffefeae2),
|
|
appBar: ClickableAppBar(
|
|
onTap: () =>
|
|
TalkNavigator.pushSplitView(context, ChatInfo(widget.room)),
|
|
appBar: AppBar(
|
|
title: Row(
|
|
children: [
|
|
widget.avatar,
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
widget.room.displayName,
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 1,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.search),
|
|
tooltip: 'In Chat suchen',
|
|
onPressed: _openSearch,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
body: ChatBackground(
|
|
child: Column(
|
|
children: [
|
|
if (_jumpTarget case final target?)
|
|
HistoryJumpBanner(
|
|
startedAt: _jumpStartedAt,
|
|
target: target.sentAt,
|
|
loadedUntil: _jumpLoadedUntil,
|
|
onCancel: _cancelJump,
|
|
),
|
|
Expanded(
|
|
child: LoadableStateConsumer<ChatBloc, ChatState>(
|
|
isReady: (state) =>
|
|
state.chatResponse != null &&
|
|
state.currentToken == widget.room.token,
|
|
enablePullToRefresh: false,
|
|
child: (state, _) {
|
|
final items = _itemsFor(state);
|
|
_itemCount = items.length;
|
|
return ScrollablePositionedList.builder(
|
|
reverse: true,
|
|
itemScrollController: _itemScrollController,
|
|
itemPositionsListener: _positionsListener,
|
|
itemCount: items.length,
|
|
itemBuilder: (ctx, idx) => items[idx],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
ColoredBox(
|
|
color: Theme.of(context).colorScheme.surface,
|
|
child: TalkNavigator.isSecondaryVisible(context)
|
|
? input
|
|
: SafeArea(child: input),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// How many rows before the oldest one the older-history prefetch kicks in.
|
|
const _kLoadOlderPrefetchRows = 15;
|
|
|
|
class _LoadingOlderIndicator extends StatelessWidget {
|
|
const _LoadingOlderIndicator();
|
|
|
|
@override
|
|
Widget build(BuildContext context) => const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 12),
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: 22,
|
|
height: 22,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Swallows the first back gesture while the keyboard is visible so it
|
|
/// dismisses the IME instead of popping the chat — matches platform UX
|
|
/// expectations (e.g. WhatsApp/Telegram) and prevents accidental exits
|
|
/// mid-typing. A separate widget so the per-frame inset changes during the
|
|
/// keyboard animation only rebuild this scope, not the whole chat.
|
|
class _KeyboardDismissPopScope extends StatelessWidget {
|
|
final Widget child;
|
|
|
|
const _KeyboardDismissPopScope({required this.child});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0;
|
|
return PopScope(
|
|
canPop: !keyboardOpen,
|
|
onPopInvokedWithResult: (didPop, _) {
|
|
if (didPop) return;
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
},
|
|
child: child,
|
|
);
|
|
}
|
|
}
|