implemented keyboard-aware back navigation and refined message sending logic to prevent phantom drafts and handle mid-send navigation
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user