implemented keyboard-aware back navigation and refined message sending logic to prevent phantom drafts and handle mid-send navigation

This commit is contained in:
2026-05-13 18:37:14 +02:00
parent 6c7d217463
commit 37dbb7b374
2 changed files with 114 additions and 92 deletions
+99 -87
View File
@@ -189,9 +189,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
setState(() { setState(() {
_activeMatchIndex = (_activeMatchIndex + 1) % _matches.length; _activeMatchIndex = (_activeMatchIndex + 1) % _matches.length;
}); });
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActiveMatch());
(_) => _scrollToActiveMatch(),
);
} }
void _goToNextMatch() { void _goToNextMatch() {
@@ -200,9 +198,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
_activeMatchIndex = _activeMatchIndex =
(_activeMatchIndex - 1 + _matches.length) % _matches.length; (_activeMatchIndex - 1 + _matches.length) % _matches.length;
}); });
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActiveMatch());
(_) => _scrollToActiveMatch(),
);
} }
void _scrollToActiveMatch() { void _scrollToActiveMatch() {
@@ -230,8 +226,9 @@ class _ChatViewState extends State<ChatView> with RouteAware {
final activeId = _matches.isNotEmpty final activeId = _matches.isNotEmpty
? _matches[_activeMatchIndex].messageId ? _matches[_activeMatchIndex].messageId
: null; : null;
final highlightQuery = final highlightQuery = _searchActive && _searchQuery.trim().isNotEmpty
_searchActive && _searchQuery.trim().isNotEmpty ? _searchQuery : null; ? _searchQuery
: null;
final messages = <Widget>[]; final messages = <Widget>[];
final chronologicalMatchIndex = <int, int>{}; final chronologicalMatchIndex = <int, int>{};
@@ -264,8 +261,8 @@ class _ChatViewState extends State<ChatView> with RouteAware {
final isMatch = matchIds.contains(element.id); final isMatch = matchIds.contains(element.id);
final highlight = isMatch final highlight = isMatch
? (element.id == activeId ? (element.id == activeId
? SearchHighlight.active ? SearchHighlight.active
: SearchHighlight.secondary) : SearchHighlight.secondary)
: SearchHighlight.none; : SearchHighlight.none;
if (isMatch) chronologicalMatchIndex[element.id] = messages.length; if (isMatch) chronologicalMatchIndex[element.id] = messages.length;
@@ -320,87 +317,102 @@ class _ChatViewState extends State<ChatView> with RouteAware {
} }
@override @override
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) {
backgroundColor: const Color(0xffefeae2), // Swallow the first back gesture while the keyboard is visible so it
appBar: _searchActive // dismisses the IME instead of popping the chat — matches platform UX
? ChatSearchAppBar( // expectations (e.g. WhatsApp/Telegram) and prevents accidental exits
controller: _searchTextController, // mid-typing.
matchCount: _matches.length, final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0;
activeIndex: _matches.isEmpty ? -1 : _activeMatchIndex, return PopScope(
onChanged: _onSearchChanged, canPop: !keyboardOpen,
onClose: _exitSearchMode, onPopInvokedWithResult: (didPop, _) {
onPrevious: _matches.isEmpty ? null : _goToPreviousMatch, if (didPop) return;
onNext: _matches.isEmpty ? null : _goToNextMatch, FocusManager.instance.primaryFocus?.unfocus();
) },
: ClickableAppBar( child: Scaffold(
onTap: () => backgroundColor: const Color(0xffefeae2),
TalkNavigator.pushSplitView(context, ChatInfo(widget.room)), appBar: _searchActive
appBar: AppBar( ? ChatSearchAppBar(
title: Row( controller: _searchTextController,
children: [ matchCount: _matches.length,
widget.avatar, activeIndex: _matches.isEmpty ? -1 : _activeMatchIndex,
const SizedBox(width: 10), onChanged: _onSearchChanged,
Expanded( onClose: _exitSearchMode,
child: Text( onPrevious: _matches.isEmpty ? null : _goToPreviousMatch,
widget.room.displayName, onNext: _matches.isEmpty ? null : _goToNextMatch,
overflow: TextOverflow.ellipsis, )
maxLines: 1, : 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(
actions: [ icon: const Icon(Icons.search),
IconButton( tooltip: 'In Chat suchen',
icon: const Icon(Icons.search), onPressed: _enterSearchMode,
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),
), ),
), ),
body: DecoratedBox( child: Column(
decoration: BoxDecoration( children: [
image: DecorationImage( Expanded(
image: const AssetImage('assets/background/chat.png'), child: LoadableStateConsumer<ChatBloc, ChatState>(
scale: 1.5, isReady: (state) =>
opacity: 1, state.chatResponse != null &&
repeat: ImageRepeat.repeat, state.currentToken == widget.room.token,
invertColors: AppTheme.isDarkMode(context), enablePullToRefresh: false,
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,
),
),
),
],
),
), ),
), ),
child: Column( );
children: [ }
Expanded(
child: LoadableStateConsumer<ChatBloc, ChatState>(
isReady: (state) =>
state.chatResponse != null &&
state.currentToken == widget.room.token,
enablePullToRefresh: false,
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,
),
),
),
],
),
),
);
} }
@@ -110,17 +110,27 @@ class _ChatTextfieldState extends State<ChatTextfield> {
if (_textBoxController.text.isEmpty) return; if (_textBoxController.text.isEmpty) return;
final text = _textBoxController.text; final text = _textBoxController.text;
final replyTo = chatBloc.state.data?.referenceMessageId?.toString(); final replyTo = chatBloc.state.data?.referenceMessageId?.toString();
final ownToken = widget.sendToToken;
setState(() => _sendError = null); setState(() => _sendError = null);
await SendMessage( await SendMessage(
widget.sendToToken, ownToken,
SendMessageParams(text, replyTo: replyTo), SendMessageParams(text, replyTo: replyTo),
).run(); ).run();
if (!mounted) return; // Reached only on success — SendMessage.run() throws on failure and
chatBloc.refresh(); // skips this block, leaving the persisted draft as a recovery aid.
_textBoxController.text = ''; // Drafts live on the global SettingsCubit keyed by token, so clear
// them even when the user navigated away mid-send — otherwise the
// sent message lingers as a phantom draft on the chat list.
_setDraft(''); _setDraft('');
chatBloc.setReferenceMessageId(null);
_setDraftReply(null); _setDraftReply(null);
// The global ChatBloc may already point at a different chat (user
// switched mid-send); only touch it while it still references us.
if (chatBloc.state.data?.currentToken == ownToken) {
chatBloc.setReferenceMessageId(null);
chatBloc.refresh();
}
if (!mounted) return;
_textBoxController.text = '';
} }
@override @override