feat: поиск

This commit is contained in:
torvalds
2026-07-03 22:36:11 +07:00
parent b7b155c520
commit cec37c0a30
4 changed files with 665 additions and 41 deletions
+34 -2
View File
@@ -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<List<Map<String, dynamic>>> 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>()
.map((e) => Map<String, dynamic>.from(e.cast()))
.toList();
}
Future<List<CachedMessage>> _mergeEditHistory(
int accountId,
int chatId,
+4 -4
View File
@@ -6,16 +6,16 @@ enum ChatChromeStyle { color, blur, none }
class AppChatChrome {
static const prefKey = 'app_chat_chrome';
static final ValueNotifier<ChatChromeStyle> 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;
}
}
@@ -55,6 +55,7 @@ class ChatInfoScreen extends StatefulWidget {
class _ChatInfoScreenState extends State<ChatInfoScreen> {
final _tabScrollController = ScrollController();
final _avatarHeroKey = GlobalKey();
int _myId = 0;
bool _isLoading = true;
@@ -1102,6 +1103,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
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<ChatInfoScreen> {
children: [
Center(
child: AvatarHero(
key: _avatarHeroKey,
tag: 'chatAvatar_${widget.chatId}',
name: widget.name,
imageUrl: widget.imageUrl.isNotEmpty ? widget.imageUrl : null,
+624 -35
View File
@@ -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<String> highlights;
const _MessageSearchResult({
required this.id,
required this.time,
required this.senderId,
required this.text,
required this.highlights,
});
static _MessageSearchResult? fromRaw(Map<dynamic, dynamic> 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<String>().toList()
: const <String>[];
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<ChatScreen>
final ValueNotifier<CachedMessage?> _replyTo = ValueNotifier(null);
final ValueNotifier<String?> _highlightMessageId = ValueNotifier(null);
final ValueNotifier<bool> _searchMode = ValueNotifier(false);
late final AnimationController _searchAnim;
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
final ValueNotifier<List<_MessageSearchResult>> _searchResults =
ValueNotifier(const []);
final ValueNotifier<bool> _searchLoading = ValueNotifier(false);
final ValueNotifier<bool> _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<ChatScreen>
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<Object>? _combinedItemsCache;
@@ -449,6 +499,12 @@ class _ChatScreenState extends State<ChatScreen>
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<ChatScreen>
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<ChatScreen>
),
],
);
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<ChatScreen>
}
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<ChatScreen>
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<ChatScreen>
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<Set<String>>(
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<ChatScreen>
),
),
),
if (s > 0)
IgnorePointer(
ignoring: s < 0.5,
child: Opacity(
opacity: s,
child: _searchTopBar(cs, glossy),
),
),
],
),
);
@@ -2069,6 +2170,71 @@ class _ChatScreenState extends State<ChatScreen>
);
}
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<ChatScreen>
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<ChatScreen>
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<void> _runSearch(String query) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return;
final seq = ++_searchSeq;
_searchLoading.value = true;
List<Map<String, dynamic>> 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<void> _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<String> 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 = <List<int>>[];
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 = <List<int>>[];
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 = <TextSpan>[];
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<bool>(
valueListenable: _searchLoading,
builder: (context, loading, _) =>
ValueListenableBuilder<List<_MessageSearchResult>>(
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<bool>(
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<Object> _buildCombinedItems() {
final key = Object.hash(_messagesRev.value, _messages.length);
final cached = _combinedItemsCache;
@@ -3629,12 +4164,17 @@ class _ChatScreenState extends State<ChatScreen>
final bottomInset = _keyboardReserve > 0
? math.max(mq.viewInsets.bottom, _keyboardReserve)
: mq.viewInsets.bottom;
return ValueListenableBuilder<Set<String>>(
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<ChatScreen>
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<ChatScreen>
}
Widget _buildColorBody() {
final cs = Theme.of(context).colorScheme;
return Column(
children: [
Expanded(
@@ -3687,6 +4232,7 @@ class _ChatScreenState extends State<ChatScreen>
bottom: 0,
child: _buildCommandPanel(),
),
_buildSearchOverlay(cs),
],
),
),
@@ -3696,6 +4242,8 @@ class _ChatScreenState extends State<ChatScreen>
}
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<ChatScreen>
? _buildShimmerLoading()
: _buildMessagesList(),
),
_buildSearchOverlay(cs),
if (vignette) ...[
Positioned(
top: 0,
left: 0,
right: 0,
child: _buildEdgeVignette(cs, top: true),
),
ValueListenableBuilder<double>(
valueListenable: _composerHeight,
builder: (context, height, _) => Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildEdgeVignette(cs, top: false, height: height),
),
),
],
ValueListenableBuilder<double>(
valueListenable: _composerHeight,
builder: (context, height, _) => Positioned(
@@ -3736,6 +4302,29 @@ class _ChatScreenState extends State<ChatScreen>
);
}
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<int>(
valueListenable: _messagesRev,