feat(search): экран поиска — чаты, контакты, сообщения и поиск по номеру

This commit is contained in:
klockky
2026-06-21 12:48:08 +00:00
parent ad04225a62
commit 23a1bed46a
5 changed files with 567 additions and 32 deletions
+82
View File
@@ -161,6 +161,22 @@ class CachedChat {
}; };
} }
class ChatSearchHit {
final int id;
final String type;
final String? title;
final String? avatarUrl;
final String? subtitle;
const ChatSearchHit({
required this.id,
required this.type,
this.title,
this.avatarUrl,
this.subtitle,
});
}
sealed class MessageEvent { sealed class MessageEvent {
final int chatId; final int chatId;
const MessageEvent(this.chatId); const MessageEvent(this.chatId);
@@ -1151,6 +1167,72 @@ class ChatsModule {
return packet.payload; return packet.payload;
} }
static List<ChatSearchHit> _parseSearchResult(dynamic payload) {
final result = (payload as Map?)?['result'];
if (result is! List) return const [];
final hits = <ChatSearchHit>[];
for (final item in result) {
if (item is! Map) continue;
final chat = item['chat'];
if (chat is! Map) continue;
final id = chat['id'];
if (id is! int) continue;
final last = chat['lastMessage'];
final link = chat['link'];
hits.add(ChatSearchHit(
id: id,
type: (chat['type'] as String?) ?? 'CHAT',
title: chat['title'] as String?,
avatarUrl: chat['baseIconUrl'] as String?,
subtitle: link is String && link.isNotEmpty
? '@$link'
: (last is Map ? last['text'] as String? : null),
));
}
return hits;
}
static Future<List<ChatSearchHit>> searchChats(
Api api,
String query, {
int count = 50,
}) async {
final term = query.trim();
if (term.isEmpty) return const [];
try {
final packet = await api.sendRequest(Opcode.chatSearch, {
'count': count,
'query': term,
});
if (packet.isError) return const [];
return _parseSearchResult(packet.payload);
} catch (e) {
logger.w('searchChats failed: $e');
return const [];
}
}
static Future<List<ChatSearchHit>> searchPublic(
Api api,
String query, {
int count = 20,
}) async {
final term = query.trim();
if (term.isEmpty) return const [];
try {
final packet = await api.sendRequest(Opcode.publicSearch, {
'type': 'ALL',
'count': count,
'query': term,
});
if (packet.isError) return const [];
return _parseSearchResult(packet.payload);
} catch (e) {
logger.w('searchPublic failed: $e');
return const [];
}
}
static Future<CachedChat?> createGroupChat( static Future<CachedChat?> createGroupChat(
Api api, { Api api, {
required String title, required String title,
+46
View File
@@ -54,9 +54,55 @@ class CachedContact {
} }
} }
class PhoneLookupResult {
final int id;
final String? name;
final String? avatarUrl;
const PhoneLookupResult({required this.id, this.name, this.avatarUrl});
}
class ContactsModule { class ContactsModule {
static final ValueNotifier<int> revision = ValueNotifier<int>(0); static final ValueNotifier<int> revision = ValueNotifier<int>(0);
static Future<PhoneLookupResult?> findByPhone(Api api, String phone) async {
final normalized = _normalizePhone(phone);
if (normalized == null) return null;
final packet = await api.sendRequest(Opcode.contactInfoByPhone, {
'phone': normalized,
});
if (packet.isError) return null;
final contact = (packet.payload as Map?)?['contact'];
if (contact is! Map) return null;
final id = contact['id'];
if (id is! int) return null;
String? name;
final names = contact['names'];
if (names is List) {
final n = names.firstWhere((e) => e is Map, orElse: () => null);
if (n is Map) {
final first =
(n['firstName'] as String?) ?? (n['name'] as String?) ?? '';
final last = (n['lastName'] as String?) ?? '';
final full = '$first $last'.trim();
if (full.isNotEmpty) name = full;
}
}
return PhoneLookupResult(
id: id,
name: name,
avatarUrl: contact['baseUrl'] as String?,
);
}
static String? _normalizePhone(String raw) {
final digits = raw.replaceAll(RegExp(r'[^\d]'), '');
if (digits.length < 5) return null;
return '+$digits';
}
static Future<CachedContact?> addContact( static Future<CachedContact?> addContact(
Api api, Api api,
int id, int id,
+44
View File
@@ -578,6 +578,50 @@ class AppDatabase {
); );
} }
static String _escapeLike(String value) =>
value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_');
static Future<List<Map<String, dynamic>>> searchContacts(
int accountId,
String query, {
int limit = 30,
}) async {
final term = query.trim();
if (term.isEmpty) return const [];
final db = await _instance;
final like = '%${_escapeLike(term)}%';
return db.query(
'contacts',
where: 'account_id = ? AND '
"(first_name LIKE ? ESCAPE '\\' OR last_name LIKE ? ESCAPE '\\' "
"OR CAST(phone AS TEXT) LIKE ? ESCAPE '\\')",
whereArgs: [accountId, like, like, like],
orderBy: 'first_name ASC, last_name ASC',
limit: limit,
);
}
static Future<List<Map<String, dynamic>>> searchMessages(
int accountId,
String query, {
int limit = 50,
}) async {
final term = query.trim();
if (term.isEmpty) return const [];
final db = await _instance;
final like = '%${_escapeLike(term)}%';
return db.rawQuery(
'SELECT m.id AS id, m.chat_id AS chat_id, m.sender_id AS sender_id, '
'm.text AS text, m.time AS time, '
'c.title AS chat_title, c.icon_url AS chat_icon, c.type AS chat_type '
'FROM messages m '
'LEFT JOIN chats_cache c ON c.id = m.chat_id AND c.account_id = m.account_id '
"WHERE m.account_id = ? AND m.deleted = 0 AND m.text LIKE ? ESCAPE '\\' "
'ORDER BY m.time DESC LIMIT ?',
[accountId, like, limit],
);
}
static Future<List<Map<String, dynamic>>> loadChatsByIds( static Future<List<Map<String, dynamic>>> loadChatsByIds(
int accountId, int accountId,
List<int> ids, List<int> ids,
@@ -8,6 +8,7 @@ import 'dart:math';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
import 'search_screen.dart';
import 'create_group_flow.dart'; import 'create_group_flow.dart';
import '../../widgets/adaptive_shell.dart'; import '../../widgets/adaptive_shell.dart';
import '../../widgets/online_dot.dart'; import '../../widgets/online_dot.dart';
@@ -1271,43 +1272,39 @@ class _ChatListScreenState extends State<ChatListScreen>
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 3, 20, 8), padding: const EdgeInsets.fromLTRB(20, 3, 20, 8),
child: GlossyPill( child: GestureDetector(
color: cs.surfaceContainerHighest, behavior: HitTestBehavior.opaque,
borderRadius: BorderRadius.circular(50), onTap: () => pushSwipeable(
padding: const EdgeInsets.symmetric( context,
horizontal: 16, (_) => const SearchScreen(),
), ),
depth: 6, child: GlossyPill(
child: SizedBox( color: cs.surfaceContainerHighest,
height: 44, borderRadius: BorderRadius.circular(50),
child: Row( padding: const EdgeInsets.symmetric(
children: [ horizontal: 16,
Icon( ),
Symbols.search, depth: 6,
color: cs.outline, child: SizedBox(
size: 20, height: 44,
weight: 400, child: Row(
), children: [
const SizedBox(width: 10), Icon(
Expanded( Symbols.search,
child: TextField( color: cs.outline,
size: 20,
weight: 400,
),
const SizedBox(width: 10),
Text(
'Поиск',
style: TextStyle( style: TextStyle(
color: cs.onSurface, color: cs.outline,
fontSize: 15, fontSize: 15,
), ),
decoration: InputDecoration(
hintText: 'Поиск',
hintStyle: TextStyle(
color: cs.outline,
fontSize: 15,
),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
), ),
), ],
], ),
), ),
), ),
), ),
@@ -0,0 +1,366 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../main.dart';
import '../../../backend/modules/chats.dart';
import '../../../backend/modules/contacts.dart';
import '../../../core/storage/app_database.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/swipe_route.dart';
import '../contacts/contact_profile_screen.dart';
import 'chat_screen.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
Timer? _debounce;
int _seq = 0;
int? _accountId;
bool _loading = false;
PhoneLookupResult? _phoneResult;
List<Map<String, dynamic>> _contacts = const [];
List<ChatSearchHit> _chats = const [];
List<Map<String, dynamic>> _messages = const [];
List<ChatSearchHit> _public = const [];
@override
void initState() {
super.initState();
AppDatabase.loadActiveProfile().then((p) {
if (mounted) _accountId = p?.id;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focusNode.requestFocus();
});
}
@override
void dispose() {
_debounce?.cancel();
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
void _onChanged(String value) {
_debounce?.cancel();
if (value.trim().isEmpty) {
_seq++;
setState(() {
_loading = false;
_phoneResult = null;
_contacts = const [];
_chats = const [];
_messages = const [];
_public = const [];
});
return;
}
_debounce = Timer(const Duration(milliseconds: 300), _runSearch);
}
Future<void> _runSearch() async {
final query = _controller.text.trim();
if (query.isEmpty) return;
final token = ++_seq;
setState(() => _loading = true);
final accountId = _accountId;
final phoneQuery = _phoneCandidate(query);
final results = await Future.wait([
accountId == null
? Future.value(const <Map<String, dynamic>>[])
: AppDatabase.searchContacts(accountId, query),
ChatsModule.searchChats(api, query),
accountId == null
? Future.value(const <Map<String, dynamic>>[])
: AppDatabase.searchMessages(accountId, query),
ChatsModule.searchPublic(api, query),
phoneQuery == null
? Future<PhoneLookupResult?>.value(null)
: ContactsModule.findByPhone(api, phoneQuery),
]);
if (!mounted || token != _seq) return;
final chats = results[1] as List<ChatSearchHit>;
final chatIds = chats.map((c) => c.id).toSet();
final public = (results[3] as List<ChatSearchHit>)
.where((c) => !chatIds.contains(c.id))
.toList();
setState(() {
_phoneResult = results[4] as PhoneLookupResult?;
_contacts = results[0] as List<Map<String, dynamic>>;
_chats = chats;
_messages = results[2] as List<Map<String, dynamic>>;
_public = public;
_loading = false;
});
}
String _contactName(Map<String, dynamic> row) {
final first = (row['first_name'] as String?)?.trim() ?? '';
final last = (row['last_name'] as String?)?.trim() ?? '';
final name = '$first $last'.trim();
return name.isEmpty ? '+${row['phone']}' : name;
}
void _openChat(int chatId, String name, String? avatarUrl, String type) {
pushSwipeable(
context,
(_) => ChatScreen(
chatId: chatId,
name: name,
imageUrl: avatarUrl ?? '',
chatType: type,
),
);
}
void _openContact(Map<String, dynamic> row) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ContactProfileScreen(
contactId: row['id'] as int,
initialName: _contactName(row),
initialAvatarUrl: row['base_url'] as String?,
),
),
);
}
String? _phoneCandidate(String query) {
if (!RegExp(r'^[+\d\s\-()]+$').hasMatch(query)) return null;
final digits = query.replaceAll(RegExp(r'[^\d]'), '');
if (digits.length < 5) return null;
return query;
}
void _openPhoneResult(PhoneLookupResult result) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ContactProfileScreen(
contactId: result.id,
initialName: result.name,
initialAvatarUrl: result.avatarUrl,
),
),
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final query = _controller.text.trim();
final hasResults = _phoneResult != null ||
_contacts.isNotEmpty ||
_chats.isNotEmpty ||
_messages.isNotEmpty ||
_public.isNotEmpty;
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBar(
backgroundColor: cs.surface,
elevation: 0,
scrolledUnderElevation: 0,
titleSpacing: 0,
leading: IconButton(
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
onPressed: () => Navigator.of(context).pop(),
),
title: TextField(
controller: _controller,
focusNode: _focusNode,
onChanged: _onChanged,
style: TextStyle(color: cs.onSurface, fontSize: 16),
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Поиск',
hintStyle: TextStyle(color: cs.outline, fontSize: 16),
border: InputBorder.none,
isDense: true,
),
),
actions: [
if (query.isNotEmpty)
IconButton(
icon: Icon(Symbols.close, color: cs.onSurfaceVariant),
onPressed: () {
_controller.clear();
_onChanged('');
_focusNode.requestFocus();
},
),
],
),
body: _buildBody(cs, query, hasResults),
);
}
Widget _buildBody(ColorScheme cs, String query, bool hasResults) {
if (query.isEmpty) {
return _buildHint(cs, Symbols.search, 'Начните вводить запрос');
}
if (!hasResults) {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
return _buildHint(cs, Symbols.search_off, 'Ничего не найдено');
}
final phoneResult = _phoneResult;
return ListView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
children: [
if (_loading)
const LinearProgressIndicator(minHeight: 2),
if (phoneResult != null) ...[
_sectionHeader(cs, 'По номеру'),
_ResultTile(
name: phoneResult.name ?? '',
imageUrl: phoneResult.avatarUrl,
subtitle: query,
onTap: () => _openPhoneResult(phoneResult),
),
],
if (_contacts.isNotEmpty) ...[
_sectionHeader(cs, 'Контакты'),
for (final row in _contacts)
_ResultTile(
name: _contactName(row),
imageUrl: row['base_url'] as String?,
subtitle: '+${row['phone']}',
onTap: () => _openContact(row),
),
],
if (_chats.isNotEmpty) ...[
_sectionHeader(cs, 'Чаты'),
for (final hit in _chats) _chatTile(hit),
],
if (_messages.isNotEmpty) ...[
_sectionHeader(cs, 'Сообщения'),
for (final row in _messages)
_ResultTile(
name: (row['chat_title'] as String?) ?? '',
imageUrl: row['chat_icon'] as String?,
subtitle: (row['text'] as String?)?.trim(),
onTap: () => _openChat(
row['chat_id'] as int,
(row['chat_title'] as String?) ?? '',
row['chat_icon'] as String?,
(row['chat_type'] as String?) ?? 'CHAT',
),
),
],
if (_public.isNotEmpty) ...[
_sectionHeader(cs, 'Глобальный поиск'),
for (final hit in _public) _chatTile(hit),
],
const SizedBox(height: 16),
],
);
}
Widget _chatTile(ChatSearchHit hit) => _ResultTile(
name: hit.title ?? '',
imageUrl: hit.avatarUrl,
subtitle: hit.subtitle,
onTap: () => _openChat(hit.id, hit.title ?? '', hit.avatarUrl, hit.type),
);
Widget _sectionHeader(ColorScheme cs, String title) => Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 6),
child: Text(
title,
style: TextStyle(
color: cs.primary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
);
Widget _buildHint(ColorScheme cs, IconData icon, String text) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: cs.outline),
const SizedBox(height: 12),
Text(text, style: TextStyle(color: cs.outline, fontSize: 15)),
],
),
);
}
class _ResultTile extends StatelessWidget {
final String name;
final String? imageUrl;
final String? subtitle;
final VoidCallback onTap;
const _ResultTile({
required this.name,
required this.onTap,
this.imageUrl,
this.subtitle,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final sub = subtitle?.trim();
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Row(
children: [
KometAvatar(name: name, size: 48, imageUrl: imageUrl),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name.isEmpty ? 'Без названия' : name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
if (sub != null && sub.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
sub,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 14,
),
),
],
],
),
),
],
),
),
);
}
}