Files
Client/lib/view/pages/talk/chat_view.dart
T

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,
);
}
}