Files
Qlyra/lib/frontend/screens/chats/chat/view/search_view.dart
T

334 lines
9.8 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/core/config/app_animations.dart';
import 'package:komet/core/config/app_chat_chrome.dart';
import 'package:komet/core/utils/format.dart';
import 'package:komet/frontend/widgets/animated_lottie_icon.dart';
import 'package:komet/frontend/widgets/glossy_pill.dart';
import 'package:komet/frontend/widgets/komet_avatar.dart';
import 'package:komet/frontend/widgets/small_spinner.dart';
import 'package:komet/frontend/screens/chats/chat/chat_search_controller.dart';
import 'package:komet/frontend/screens/chats/chat/message_search_result.dart';
import '../../../../../core/config/app_fonts.dart';
class SearchTopBar extends StatelessWidget {
const SearchTopBar({
super.key,
required this.cs,
required this.glossy,
required this.search,
required this.focusNode,
required this.onClose,
});
final ColorScheme cs;
final bool glossy;
final ChatSearchController search;
final FocusNode focusNode;
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
final field = TextField(
controller: search.searchController,
focusNode: focusNode,
textInputAction: TextInputAction.search,
onSubmitted: search.submit,
cursorColor: cs.primary,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontFamily: displayFontOf(context),
),
decoration: InputDecoration(
hintText: 'Поиск...',
hintStyle: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 16,
fontFamily: displayFontOf(context),
),
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: onClose,
);
final searchBtn = IconButton(
icon: AnimatedLottieIcon(
asset: AppAnimations.search,
color: cs.onSurface,
size: 24,
active: true,
animateOnMount: true,
),
onPressed: () => search.submit(search.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,
],
),
),
),
);
}
}
class SearchOverlay extends StatelessWidget {
const SearchOverlay({
super.key,
required this.cs,
required this.searchAnim,
required this.search,
required this.onOpenResult,
required this.senderName,
required this.senderAvatar,
});
final ColorScheme cs;
final Animation<double> searchAnim;
final ChatSearchController search;
final void Function(MessageSearchResult) onOpenResult;
final String Function(int) senderName;
final String? Function(int) senderAvatar;
@override
Widget build(BuildContext context) {
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: _resultsContent(context),
),
),
),
);
},
);
}
Widget _resultsContent(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: search.loading,
builder: (context, loading, _) =>
ValueListenableBuilder<List<MessageSearchResult>>(
valueListenable: search.results,
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) =>
_tile(context, results[index]),
);
}
if (loading) {
return Center(
child: SmallSpinner(size: 26, color: cs.onSurfaceVariant),
);
}
return ValueListenableBuilder<bool>(
valueListenable: search.performed,
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,
),
),
),
);
},
);
},
),
);
}
Widget _tile(BuildContext context, MessageSearchResult r) {
final name = senderName(r.senderId);
final date = formatDateWords(DateTime.fromMillisecondsSinceEpoch(r.time));
return InkWell(
onTap: () => onOpenResult(r),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
KometAvatar(
name: name,
imageUrl: senderAvatar(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: displayFontOf(context),
),
),
),
const SizedBox(width: 8),
Text(
date,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
),
),
],
),
const SizedBox(height: 2),
_highlighted(r.text, r.highlights),
],
),
),
],
),
),
);
}
Widget _highlighted(String text, List<String> highlights) {
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,
);
}
}