334 lines
9.8 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|