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

337 lines
10 KiB
Dart

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 '../../../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 '../../../theming/app_theme.dart';
import '../../../widget/clickable_app_bar.dart';
import '../../../widget/user_avatar.dart';
import 'data/chat_search_controller.dart';
import 'details/chat_info.dart';
import 'talk_navigator.dart';
import 'widgets/chat_bubble.dart';
import 'widgets/chat_search_app_bar.dart';
import 'widgets/chat_textfield.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> {
final ItemScrollController _itemScrollController = ItemScrollController();
final TextEditingController _searchTextController = TextEditingController();
final Map<int, int> _matchIndices = {};
bool _searchActive = false;
String _searchQuery = '';
List<ChatSearchMatch> _matches = const [];
int _activeMatchIndex = 0;
GetChatResponse? _matchesComputedFor;
String? _matchesComputedQuery;
@override
void dispose() {
_searchTextController.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant ChatView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.room.token != oldWidget.room.token && _searchActive) {
_exitSearchMode();
}
}
void _refresh() {
context.read<ChatBloc>().setToken(widget.room.token);
}
void _enterSearchMode() {
setState(() {
_searchActive = true;
_searchQuery = '';
_matches = const [];
_activeMatchIndex = 0;
_matchesComputedFor = null;
_matchesComputedQuery = null;
_searchTextController.clear();
});
}
void _exitSearchMode() {
setState(() {
_searchActive = false;
_searchQuery = '';
_matches = const [];
_activeMatchIndex = 0;
_matchIndices.clear();
_matchesComputedFor = null;
_matchesComputedQuery = null;
_searchTextController.clear();
});
}
void _onSearchChanged(String q) {
final chatResponse = context.read<ChatBloc>().state.data?.chatResponse;
setState(() {
_searchQuery = q;
_activeMatchIndex = 0;
if (chatResponse != null) {
_recomputeMatches(chatResponse);
} else {
_matches = const [];
_matchesComputedFor = null;
_matchesComputedQuery = null;
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _matches.isNotEmpty) _scrollToActiveMatch();
});
}
void _recomputeMatches(GetChatResponse response) {
_matches = ChatSearchController.findMatches(response, _searchQuery);
_activeMatchIndex = _activeMatchIndex.clamp(
0,
math.max(0, _matches.length - 1),
);
_matchesComputedFor = response;
_matchesComputedQuery = _searchQuery;
}
void _goToPreviousMatch() {
if (_matches.isEmpty) return;
setState(() {
_activeMatchIndex = (_activeMatchIndex + 1) % _matches.length;
});
WidgetsBinding.instance.addPostFrameCallback(
(_) => _scrollToActiveMatch(),
);
}
void _goToNextMatch() {
if (_matches.isEmpty) return;
setState(() {
_activeMatchIndex =
(_activeMatchIndex - 1 + _matches.length) % _matches.length;
});
WidgetsBinding.instance.addPostFrameCallback(
(_) => _scrollToActiveMatch(),
);
}
void _scrollToActiveMatch() {
if (_matches.isEmpty) return;
if (!_itemScrollController.isAttached) return;
final id = _matches[_activeMatchIndex].messageId;
final idx = _matchIndices[id];
if (idx == null) return;
_itemScrollController.scrollTo(
index: idx,
alignment: 0.4,
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
);
}
List<Widget> _buildMessages(GetChatResponse response) {
if (_searchActive &&
(response != _matchesComputedFor ||
_searchQuery != _matchesComputedQuery)) {
_recomputeMatches(response);
}
final matchIds = _matches.map((m) => m.messageId).toSet();
final activeId = _matches.isNotEmpty
? _matches[_activeMatchIndex].messageId
: null;
final highlightQuery =
_searchActive && _searchQuery.trim().isNotEmpty ? _searchQuery : null;
final messages = <Widget>[];
final chronologicalMatchIndex = <int, int>{};
var lastDate = DateTime.now();
for (final element in response.sortByTimestamp()) {
final elementDate = DateTime.fromMillisecondsSinceEpoch(
element.timestamp * 1000,
);
if (element.systemMessage.contains('reaction')) continue;
if (element.systemMessage.contains('poll_voted')) continue;
final commonRead = int.parse(
response.headers?['x-chat-last-common-read'] ?? '0',
);
if (!elementDate.isSameDay(lastDate)) {
lastDate = elementDate;
messages.add(
ChatBubble(
context: context,
isSender: false,
bubbleData: GetChatResponseObject.getDateDummy(element.timestamp),
chatData: widget.room,
refetch: ({bool renew = false}) => _refresh(),
),
);
}
final isMatch = matchIds.contains(element.id);
final highlight = isMatch
? (element.id == activeId
? SearchHighlight.active
: SearchHighlight.secondary)
: SearchHighlight.none;
if (isMatch) chronologicalMatchIndex[element.id] = messages.length;
messages.add(
ChatBubble(
context: context,
isSender:
element.actorId == widget.selfId &&
element.messageType == GetRoomResponseObjectMessageType.comment,
bubbleData: element,
chatData: widget.room,
refetch: ({bool renew = false}) => _refresh(),
isRead: element.id <= commonRead,
selfId: widget.selfId,
highlightQuery: highlightQuery,
matchHighlight: highlight,
),
);
}
if (response.data.length >= 200) {
messages.insert(
0,
ChatBubble(
context: context,
isSender: false,
bubbleData: GetChatResponseObject.getTextDummy(
'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. '
'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de',
),
chatData: widget.room,
refetch: ({bool renew = false}) => _refresh(),
),
);
chronologicalMatchIndex.updateAll((_, v) => v + 1);
}
final total = messages.length;
_matchIndices
..clear()
..addEntries(
chronologicalMatchIndex.entries.map(
(e) => MapEntry(e.key, (total - 1) - e.value),
),
);
return messages;
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: const Color(0xffefeae2),
appBar: _searchActive
? ChatSearchAppBar(
controller: _searchTextController,
matchCount: _matches.length,
activeIndex: _matches.isEmpty ? -1 : _activeMatchIndex,
onChanged: _onSearchChanged,
onClose: _exitSearchMode,
onPrevious: _matches.isEmpty ? null : _goToPreviousMatch,
onNext: _matches.isEmpty ? null : _goToNextMatch,
)
: 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: _enterSearchMode,
),
],
),
),
body: DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: const AssetImage('assets/background/chat.png'),
scale: 1.5,
opacity: 1,
repeat: ImageRepeat.repeat,
invertColors: AppTheme.isDarkMode(context),
),
),
child: Column(
children: [
Expanded(
child: LoadableStateConsumer<ChatBloc, ChatState>(
isReady: (state) =>
state.chatResponse != null &&
state.currentToken == widget.room.token,
child: (state, _) {
final items =
_buildMessages(state.chatResponse!).reversed.toList();
return ScrollablePositionedList.builder(
reverse: true,
itemScrollController: _itemScrollController,
itemCount: items.length,
itemBuilder: (ctx, idx) => items[idx],
);
},
),
),
ColoredBox(
color: Theme.of(context).colorScheme.surface,
child: TalkNavigator.isSecondaryVisible(context)
? ChatTextfield(widget.room.token, selfId: widget.selfId)
: SafeArea(
child: ChatTextfield(
widget.room.token,
selfId: widget.selfId,
),
),
),
],
),
),
);
}