diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index d23ff47..93cce58 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -568,6 +568,8 @@ class MessagesModule { int chatId, { int? fromTime, int count = 50, + int forward = 0, + int? backward, }) async { final payload = { 'chatId': chatId, @@ -575,8 +577,8 @@ class MessagesModule { fromTime ?? (DateTime.now().millisecondsSinceEpoch + 86400000), // +1 день для запаса - 'forward': 0, - 'backward': count, + 'forward': forward, + 'backward': backward ?? count, 'getMessages': true, }; @@ -623,6 +625,36 @@ class MessagesModule { return toSave; } + /// Поиск сообщений в чате по строке [query] (opcode 73). + /// + /// Возвращает сырые записи результата вида + /// `{'message': {...}, 'highlights': [...]}`, отсортированные сервером + /// от новых к старым. + Future>> searchMessages( + int chatId, + String query, { + int count = 30, + }) async { + final response = await _api.sendRequest(Opcode.msgSearch, { + 'chatId': chatId, + 'query': query, + 'count': count, + }); + + if (!response.isOk) return const []; + + final data = response.payload; + if (data is! Map) return const []; + + final result = data['result']; + if (result is! List) return const []; + + return result + .whereType() + .map((e) => Map.from(e.cast())) + .toList(); + } + Future> _mergeEditHistory( int accountId, int chatId, diff --git a/lib/core/config/app_chat_chrome.dart b/lib/core/config/app_chat_chrome.dart index f9e8bd9..36fc54a 100644 --- a/lib/core/config/app_chat_chrome.dart +++ b/lib/core/config/app_chat_chrome.dart @@ -6,16 +6,16 @@ enum ChatChromeStyle { color, blur, none } class AppChatChrome { static const prefKey = 'app_chat_chrome'; static final ValueNotifier current = - ValueNotifier(ChatChromeStyle.blur); + ValueNotifier(ChatChromeStyle.none); static ChatChromeStyle _parse(String? value) { switch (value) { case 'color': return ChatChromeStyle.color; - case 'none': - return ChatChromeStyle.none; - default: + case 'blur': return ChatChromeStyle.blur; + default: + return ChatChromeStyle.none; } } diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index d07ccfd..18b73f8 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -55,6 +55,7 @@ class ChatInfoScreen extends StatefulWidget { class _ChatInfoScreenState extends State { final _tabScrollController = ScrollController(); + final _avatarHeroKey = GlobalKey(); int _myId = 0; bool _isLoading = true; @@ -1102,6 +1103,7 @@ class _ChatInfoScreenState extends State { Widget _heroAvatar() { return AvatarHero( + key: _avatarHeroKey, tag: 'chatAvatar_${widget.chatId}', name: widget.name, imageUrl: widget.imageUrl.isNotEmpty ? widget.imageUrl : null, @@ -1129,6 +1131,7 @@ class _ChatInfoScreenState extends State { children: [ Center( child: AvatarHero( + key: _avatarHeroKey, tag: 'chatAvatar_${widget.chatId}', name: widget.name, imageUrl: widget.imageUrl.isNotEmpty ? widget.imageUrl : null, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 94c3c85..691af24 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -55,6 +55,7 @@ import '../../../models/sticker.dart'; import '../../commands/command_registry.dart'; import '../../commands/slash_command.dart'; import '../../widgets/avatar_hero.dart'; +import '../../widgets/komet_avatar.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/rich_message_controller.dart'; import '../../../core/utils/text_format.dart'; @@ -99,6 +100,42 @@ class _MessageItem { const _MessageItem(this.message, this.index); } +class _MessageSearchResult { + final String id; + final int time; + final int senderId; + final String text; + final List highlights; + + const _MessageSearchResult({ + required this.id, + required this.time, + required this.senderId, + required this.text, + required this.highlights, + }); + + static _MessageSearchResult? fromRaw(Map raw) { + final message = raw['message']; + if (message is! Map) return null; + final id = message['id']?.toString(); + if (id == null) return null; + final rawHighlights = raw['highlights']; + final highlights = rawHighlights is List + ? rawHighlights.whereType().toList() + : const []; + final time = message['time']; + final sender = message['sender']; + return _MessageSearchResult( + id: id, + time: time is int ? time : int.tryParse('${time ?? 0}') ?? 0, + senderId: sender is int ? sender : int.tryParse('${sender ?? 0}') ?? 0, + text: message['text']?.toString() ?? '', + highlights: highlights, + ); + } +} + class _RecordingDot extends StatefulWidget { final Color color; const _RecordingDot({required this.color}); @@ -358,6 +395,17 @@ class _ChatScreenState extends State final ValueNotifier _replyTo = ValueNotifier(null); final ValueNotifier _highlightMessageId = ValueNotifier(null); + final ValueNotifier _searchMode = ValueNotifier(false); + late final AnimationController _searchAnim; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + final ValueNotifier> _searchResults = + ValueNotifier(const []); + final ValueNotifier _searchLoading = ValueNotifier(false); + final ValueNotifier _searchPerformed = ValueNotifier(false); + Timer? _searchDebounce; + int _searchSeq = 0; + bool _prankActive = false; String? _prankBubbleId; final GlobalKey _prankBubbleKey = GlobalKey(); @@ -388,6 +436,8 @@ class _ChatScreenState extends State static const int _historyInitialLimit = 50; static const double _avgMessageHeight = 72.0; static const double _historyPrefetchExtent = _avgMessageHeight * 8; + static const double _glossyHeaderHeight = 76.0; + static const double _glossySearchHeight = 58.0; bool _isLoadingMore = false; bool _hasMoreHistory = true; List? _combinedItemsCache; @@ -449,6 +499,12 @@ class _ChatScreenState extends State duration: const Duration(milliseconds: 260), reverseDuration: const Duration(milliseconds: 200), ); + _searchAnim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 280), + reverseDuration: const Duration(milliseconds: 220), + ); + _searchController.addListener(_onSearchTextChanged); AppCommands.current.addListener(_updateCommandPanel); _pushSub = api.pushStream .where( @@ -998,6 +1054,15 @@ class _ChatScreenState extends State AppCommands.current.removeListener(_updateCommandPanel); _commandAnim.dispose(); _selectionAnim.dispose(); + _searchDebounce?.cancel(); + _searchAnim.dispose(); + _searchController.removeListener(_onSearchTextChanged); + _searchController.dispose(); + _searchFocusNode.dispose(); + _searchMode.dispose(); + _searchResults.dispose(); + _searchLoading.dispose(); + _searchPerformed.dispose(); _selectedIds.dispose(); _commandMatches.dispose(); _messageController.dispose(); @@ -1519,16 +1584,38 @@ class _ChatScreenState extends State ), ], ); - if (AppChatChrome.current.value != ChatChromeStyle.blur) return content; - return _FrostedPanel( - tint: cs.surfaceContainerHigh.withValues(alpha: 0.55), - border: Border( - top: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.4), - width: 0.5, + Widget wrapChrome(Widget child) { + if (AppChatChrome.current.value != ChatChromeStyle.blur) return child; + return _FrostedPanel( + tint: cs.surfaceContainerHigh.withValues(alpha: 0.55), + border: Border( + top: BorderSide( + color: cs.outlineVariant.withValues(alpha: 0.4), + width: 0.5, + ), ), - ), - child: content, + child: child, + ); + } + + final base = wrapChrome(content); + return AnimatedBuilder( + animation: _searchAnim, + builder: (context, _) { + final s = Curves.easeOut.transform(_searchAnim.value.clamp(0.0, 1.0)); + if (s == 0) return base; + if (s >= 1) return const SizedBox.shrink(); + return ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: 1 - s, + child: Opacity( + opacity: 1 - s, + child: IgnorePointer(child: base), + ), + ), + ); + }, ); } @@ -1924,21 +2011,21 @@ class _ChatScreenState extends State } Widget _withOnlineDot(ColorScheme cs, Widget avatar, {double dotSize = 12}) { - if (widget.chatType != 'DIALOG' || _myId == 0) return avatar; final otherId = widget.chatId ^ _myId; - if (otherId <= 0) return avatar; + final showDot = widget.chatType == 'DIALOG' && _myId != 0 && otherId > 0; return Stack( children: [ avatar, - Positioned( - right: 0, - bottom: 0, - child: OnlineDot( - userId: otherId, - borderColor: cs.surface, - size: dotSize, + if (showDot) + Positioned( + right: 0, + bottom: 0, + child: OnlineDot( + userId: otherId, + borderColor: cs.surface, + size: dotSize, + ), ), - ), ], ); } @@ -1997,7 +2084,10 @@ class _ChatScreenState extends State PreferredSizeWidget _buildAppBar(ColorScheme cs) { final glossy = AppVisualStyle.current.value == VisualStyle.glossy; - final height = glossy ? 76.0 : kToolbarHeight; + final searchT = Curves.easeOut.transform(_searchAnim.value.clamp(0.0, 1.0)); + final height = glossy + ? ui.lerpDouble(_glossyHeaderHeight, _glossySearchHeight, searchT)! + : kToolbarHeight; final chrome = AppChatChrome.current.value; return AppBar( backgroundColor: chrome == ChatChromeStyle.color @@ -2026,21 +2116,24 @@ class _ChatScreenState extends State title: SizedBox( height: height, child: AnimatedBuilder( - animation: _selectionAnim, + animation: Listenable.merge([_selectionAnim, _searchAnim]), builder: (context, _) { final t = Curves.easeOut.transform( _selectionAnim.value.clamp(0.0, 1.0), ); + final s = Curves.easeOut.transform( + _searchAnim.value.clamp(0.0, 1.0), + ); return ValueListenableBuilder>( valueListenable: _selectedIds, builder: (context, selected, _) => Stack( fit: StackFit.expand, children: [ - if (t < 1) + if (t < 1 && s < 1) IgnorePointer( - ignoring: t > 0.5, + ignoring: t > 0.5 || s > 0.5, child: Opacity( - opacity: 1 - t, + opacity: (1 - t) * (1 - s), child: Transform.translate( offset: Offset(0, -height * 0.4 * t), child: glossy @@ -2060,6 +2153,14 @@ class _ChatScreenState extends State ), ), ), + if (s > 0) + IgnorePointer( + ignoring: s < 0.5, + child: Opacity( + opacity: s, + child: _searchTopBar(cs, glossy), + ), + ), ], ), ); @@ -2069,6 +2170,71 @@ class _ChatScreenState extends State ); } + Widget _searchTopBar(ColorScheme cs, bool glossy) { + final field = TextField( + controller: _searchController, + focusNode: _searchFocusNode, + textInputAction: TextInputAction.search, + onSubmitted: (value) { + _searchDebounce?.cancel(); + _runSearch(value); + }, + cursorColor: cs.primary, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontFamily: 'Outfit', + ), + decoration: InputDecoration( + hintText: 'Поиск...', + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + fontFamily: 'Outfit', + ), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + ), + ); + + final backBtn = IconButton( + icon: Icon(Symbols.arrow_back, weight: glossy ? 500 : 400, + color: cs.onSurface), + onPressed: _closeSearch, + ); + final searchBtn = IconButton( + icon: Icon(Symbols.search, weight: glossy ? 500 : 400, + color: cs.onSurface), + onPressed: () { + _searchDebounce?.cancel(); + _runSearch(_searchController.text); + }, + ); + + if (!glossy) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + children: [backBtn, Expanded(child: field), searchBtn], + ), + ); + } + + return Padding( + padding: const EdgeInsets.fromLTRB(10, 4, 10, 6), + child: GlossyPill( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: SizedBox( + height: 44, + child: Row( + children: [backBtn, Expanded(child: field), searchBtn], + ), + ), + ), + ); + } + Widget _glossyHeaderRow(ColorScheme cs) { return Padding( padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), @@ -2529,7 +2695,7 @@ class _ChatScreenState extends State label: 'Видеозвонок', onTap: () {}, ), - ChatMenuItem(icon: Symbols.search, label: 'Поиск', onTap: () {}), + ChatMenuItem(icon: Symbols.search, label: 'Поиск', onTap: _openSearch), ChatMenuItem( icon: Symbols.wallpaper, label: 'Изменить обои', @@ -3457,6 +3623,375 @@ class _ChatScreenState extends State GlobalKey _keyForMessage(String messageId) => _messageKeys.putIfAbsent(messageId, () => GlobalKey()); + void _openSearch() { + if (_searchMode.value || _selectionMode) return; + _searchMode.value = true; + _searchAnim.forward(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _searchMode.value) _searchFocusNode.requestFocus(); + }); + } + + void _closeSearch() { + if (!_searchMode.value) return; + _searchDebounce?.cancel(); + _searchSeq++; + _searchFocusNode.unfocus(); + _searchMode.value = false; + _searchAnim.reverse(); + _searchController.clear(); + _searchResults.value = const []; + _searchLoading.value = false; + _searchPerformed.value = false; + } + + void _onSearchTextChanged() { + final query = _searchController.text.trim(); + _searchDebounce?.cancel(); + if (query.isEmpty) { + _searchSeq++; + _searchResults.value = const []; + _searchLoading.value = false; + _searchPerformed.value = false; + return; + } + _searchDebounce = Timer(const Duration(milliseconds: 300), () { + _runSearch(query); + }); + } + + Future _runSearch(String query) async { + final trimmed = query.trim(); + if (trimmed.isEmpty) return; + final seq = ++_searchSeq; + _searchLoading.value = true; + List> raw; + try { + raw = await messagesModule.searchMessages(widget.chatId, trimmed); + } catch (e) { + logger.e('Search error: $e'); + raw = const []; + } + if (!mounted || seq != _searchSeq) return; + final results = raw + .map(_MessageSearchResult.fromRaw) + .whereType<_MessageSearchResult>() + .toList(); + _searchResults.value = results; + _searchLoading.value = false; + _searchPerformed.value = true; + } + + Future _openSearchResult(_MessageSearchResult result) async { + _closeSearch(); + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + + if (!_messages.any((m) => m.id == result.id)) { + var guard = 0; + while (mounted && + guard < 60 && + _hasMoreHistory && + !_messages.any((m) => m.id == result.id) && + (_messages.isEmpty || _messages.first.time > result.time)) { + guard++; + final before = _messages.isEmpty ? 0 : _messages.first.time; + await _loadMoreHistory(); + if (!mounted) return; + final after = _messages.isEmpty ? 0 : _messages.first.time; + if (after == before) break; + } + if (!mounted) return; + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + } + + _scrollToLoadedMessage(result.id); + } + + void _scrollToLoadedMessage(String messageId) { + if (!_scrollController.hasClients) return; + final items = _buildCombinedItems(); + final pos = items.indexWhere( + (it) => it is _MessageItem && it.message.id == messageId, + ); + if (pos == -1) { + showCustomNotification(context, 'Сообщение не загружено'); + return; + } + + var below = 0.0; + for (var i = pos + 1; i < items.length; i++) { + below += items[i] is _DateSeparatorItem ? 44.0 : _avgMessageHeight; + } + final maxExtent = _scrollController.position.maxScrollExtent; + final estimate = below.clamp(0.0, maxExtent).toDouble(); + _scrollController.jumpTo(estimate); + + _highlightMessageId.value = messageId; + Future.delayed(const Duration(milliseconds: 1600), () { + if (_highlightMessageId.value == messageId) { + _highlightMessageId.value = null; + } + }); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _ensureVisibleRetry(messageId, 0), + ); + } + + void _ensureVisibleRetry(String messageId, int attempt) { + if (!mounted) return; + final ctx = _keyForMessage(messageId).currentContext; + if (ctx != null) { + Scrollable.ensureVisible( + ctx, + duration: const Duration(milliseconds: 280), + curve: Curves.easeOut, + alignment: 0.4, + ); + return; + } + if (attempt >= 6) return; + WidgetsBinding.instance.addPostFrameCallback( + (_) => _ensureVisibleRetry(messageId, attempt + 1), + ); + } + + String _searchSenderName(int senderId) { + if (senderId == _myId) return 'Вы'; + final cached = ContactCache.get(senderId); + if (cached != null && cached.isNotEmpty) return cached; + if (widget.chatType == 'DIALOG') return widget.name; + return 'Пользователь'; + } + + String? _searchSenderAvatar(int senderId) { + final cached = ContactCache.getAvatar(senderId); + if (cached != null && cached.isNotEmpty) return cached; + if (senderId != _myId && + widget.chatType == 'DIALOG' && + widget.imageUrl.isNotEmpty) { + return widget.imageUrl; + } + return null; + } + + Widget _buildHighlightedText( + String text, + List highlights, + ColorScheme cs, + ) { + final baseStyle = TextStyle(color: cs.onSurface, fontSize: 15); + final terms = highlights + .where((h) => h.trim().isNotEmpty) + .map((h) => h.toLowerCase()) + .toSet(); + if (text.isEmpty || terms.isEmpty) { + return Text( + text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: baseStyle, + ); + } + + final lower = text.toLowerCase(); + final ranges = >[]; + for (final term in terms) { + var start = 0; + while (true) { + final idx = lower.indexOf(term, start); + if (idx < 0) break; + ranges.add([idx, idx + term.length]); + start = idx + term.length; + } + } + if (ranges.isEmpty) { + return Text( + text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: baseStyle, + ); + } + + ranges.sort((a, b) => a[0].compareTo(b[0])); + final merged = >[]; + for (final r in ranges) { + if (merged.isNotEmpty && r[0] <= merged.last[1]) { + merged.last[1] = math.max(merged.last[1], r[1]); + } else { + merged.add([r[0], r[1]]); + } + } + + final highlightStyle = baseStyle.copyWith( + color: cs.primary, + fontWeight: FontWeight.w600, + backgroundColor: cs.primary.withValues(alpha: 0.18), + ); + final spans = []; + var cursor = 0; + for (final r in merged) { + if (r[0] > cursor) { + spans.add(TextSpan(text: text.substring(cursor, r[0]))); + } + spans.add( + TextSpan(text: text.substring(r[0], r[1]), style: highlightStyle), + ); + cursor = r[1]; + } + if (cursor < text.length) { + spans.add(TextSpan(text: text.substring(cursor))); + } + + return Text.rich( + TextSpan(style: baseStyle, children: spans), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ); + } + + Widget _buildSearchResultTile(_MessageSearchResult r, ColorScheme cs) { + final name = _searchSenderName(r.senderId); + final date = formatDateWords(DateTime.fromMillisecondsSinceEpoch(r.time)); + return InkWell( + onTap: () => _openSearchResult(r), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + KometAvatar( + name: name, + imageUrl: _searchSenderAvatar(r.senderId), + size: 44, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.primary, + fontSize: 15, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ), + const SizedBox(width: 8), + Text( + date, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 2), + _buildHighlightedText(r.text, r.highlights, cs), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildSearchOverlay(ColorScheme cs) { + return AnimatedBuilder( + animation: _searchAnim, + builder: (context, _) { + final s = Curves.easeOut.transform(_searchAnim.value.clamp(0.0, 1.0)); + if (s == 0) return const SizedBox.shrink(); + final chrome = AppChatChrome.current.value; + final topPad = chrome == ChatChromeStyle.color + ? 0.0 + : MediaQuery.paddingOf(context).top; + return Positioned.fill( + child: IgnorePointer( + ignoring: s < 0.5, + child: Opacity( + opacity: s, + child: Container( + color: cs.surface, + padding: EdgeInsets.only(top: topPad), + child: _buildSearchResultsContent(cs), + ), + ), + ), + ); + }, + ); + } + + Widget _buildSearchResultsContent(ColorScheme cs) { + return ValueListenableBuilder( + valueListenable: _searchLoading, + builder: (context, loading, _) => + ValueListenableBuilder>( + valueListenable: _searchResults, + builder: (context, results, _) { + if (results.isNotEmpty) { + return ListView.builder( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.only( + top: 4, + bottom: MediaQuery.paddingOf(context).bottom + 16, + ), + itemCount: results.length, + itemBuilder: (context, index) => + _buildSearchResultTile(results[index], cs), + ); + } + if (loading) { + return Center( + child: SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator( + strokeWidth: 2.4, + color: cs.onSurfaceVariant, + ), + ), + ); + } + return ValueListenableBuilder( + valueListenable: _searchPerformed, + builder: (context, performed, _) { + if (!performed) return const SizedBox.shrink(); + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'Поиск ничего не вернул...', + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + ), + ), + ); + }, + ); + }, + ), + ); + } + List _buildCombinedItems() { final key = Object.hash(_messagesRev.value, _messages.length); final cached = _combinedItemsCache; @@ -3629,12 +4164,17 @@ class _ChatScreenState extends State final bottomInset = _keyboardReserve > 0 ? math.max(mq.viewInsets.bottom, _keyboardReserve) : mq.viewInsets.bottom; - return ValueListenableBuilder>( - valueListenable: _selectedIds, - builder: (context, selected, child) => PopScope( - canPop: selected.isEmpty, + return ListenableBuilder( + listenable: Listenable.merge([_selectedIds, _searchMode]), + builder: (context, child) => PopScope( + canPop: _selectedIds.value.isEmpty && !_searchMode.value, onPopInvokedWithResult: (didPop, _) { - if (!didPop) _clearSelection(); + if (didPop) return; + if (_searchMode.value) { + _closeSearch(); + } else { + _clearSelection(); + } }, child: child!, ), @@ -3653,11 +4193,15 @@ class _ChatScreenState extends State onPop: widget.onClose, child: child!, ), - child: Scaffold( - backgroundColor: cs.surface, - extendBodyBehindAppBar: underlap, - appBar: _buildAppBar(cs), - body: underlap ? _buildUnderlapBody() : _buildColorBody(), + child: AnimatedBuilder( + animation: _searchAnim, + child: underlap ? _buildUnderlapBody() : _buildColorBody(), + builder: (context, body) => Scaffold( + backgroundColor: cs.surface, + extendBodyBehindAppBar: underlap, + appBar: _buildAppBar(cs), + body: body, + ), ), ), ), @@ -3667,6 +4211,7 @@ class _ChatScreenState extends State } Widget _buildColorBody() { + final cs = Theme.of(context).colorScheme; return Column( children: [ Expanded( @@ -3687,6 +4232,7 @@ class _ChatScreenState extends State bottom: 0, child: _buildCommandPanel(), ), + _buildSearchOverlay(cs), ], ), ), @@ -3696,6 +4242,8 @@ class _ChatScreenState extends State } Widget _buildUnderlapBody() { + final cs = Theme.of(context).colorScheme; + final vignette = AppChatChrome.current.value == ChatChromeStyle.none; return Stack( fit: StackFit.expand, children: [ @@ -3708,6 +4256,24 @@ class _ChatScreenState extends State ? _buildShimmerLoading() : _buildMessagesList(), ), + _buildSearchOverlay(cs), + if (vignette) ...[ + Positioned( + top: 0, + left: 0, + right: 0, + child: _buildEdgeVignette(cs, top: true), + ), + ValueListenableBuilder( + valueListenable: _composerHeight, + builder: (context, height, _) => Positioned( + left: 0, + right: 0, + bottom: 0, + child: _buildEdgeVignette(cs, top: false, height: height), + ), + ), + ], ValueListenableBuilder( valueListenable: _composerHeight, builder: (context, height, _) => Positioned( @@ -3736,6 +4302,29 @@ class _ChatScreenState extends State ); } + Widget _buildEdgeVignette(ColorScheme cs, {required bool top, double? height}) { + final double resolved; + if (height != null) { + resolved = height; + } else { + final glossy = AppVisualStyle.current.value == VisualStyle.glossy; + resolved = MediaQuery.paddingOf(context).top + + (glossy ? _glossyHeaderHeight : kToolbarHeight); + } + return IgnorePointer( + child: Container( + height: resolved, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: top ? Alignment.topCenter : Alignment.bottomCenter, + end: top ? Alignment.bottomCenter : Alignment.topCenter, + colors: [cs.surface, cs.surface.withValues(alpha: 0.0)], + ), + ), + ), + ); + } + Widget _buildMessagesList() { return ValueListenableBuilder( valueListenable: _messagesRev,