diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 40ba287..a6d826e 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -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 { final int chatId; const MessageEvent(this.chatId); @@ -1151,6 +1167,72 @@ class ChatsModule { return packet.payload; } + static List _parseSearchResult(dynamic payload) { + final result = (payload as Map?)?['result']; + if (result is! List) return const []; + final hits = []; + 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> 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> 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 createGroupChat( Api api, { required String title, diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index dac35e8..aed44e5 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -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 { static final ValueNotifier revision = ValueNotifier(0); + static Future 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 addContact( Api api, int id, diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 83030dc..37740af 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -578,6 +578,50 @@ class AppDatabase { ); } + static String _escapeLike(String value) => + value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_'); + + static Future>> 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>> 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>> loadChatsByIds( int accountId, List ids, diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index c8b0797..19a852d 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -8,6 +8,7 @@ import 'dart:math'; import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'chat_screen.dart'; +import 'search_screen.dart'; import 'create_group_flow.dart'; import '../../widgets/adaptive_shell.dart'; import '../../widgets/online_dot.dart'; @@ -1271,43 +1272,39 @@ class _ChatListScreenState extends State ), Padding( padding: const EdgeInsets.fromLTRB(20, 3, 20, 8), - child: GlossyPill( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(50), - padding: const EdgeInsets.symmetric( - horizontal: 16, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => pushSwipeable( + context, + (_) => const SearchScreen(), ), - depth: 6, - child: SizedBox( - height: 44, - child: Row( - children: [ - Icon( - Symbols.search, - color: cs.outline, - size: 20, - weight: 400, - ), - const SizedBox(width: 10), - Expanded( - child: TextField( + child: GlossyPill( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(50), + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + depth: 6, + child: SizedBox( + height: 44, + child: Row( + children: [ + Icon( + Symbols.search, + color: cs.outline, + size: 20, + weight: 400, + ), + const SizedBox(width: 10), + Text( + 'Поиск', style: TextStyle( - color: cs.onSurface, + color: cs.outline, fontSize: 15, ), - decoration: InputDecoration( - hintText: 'Поиск', - hintStyle: TextStyle( - color: cs.outline, - fontSize: 15, - ), - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/frontend/screens/chats/search_screen.dart b/lib/frontend/screens/chats/search_screen.dart new file mode 100644 index 0000000..82edd00 --- /dev/null +++ b/lib/frontend/screens/chats/search_screen.dart @@ -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 createState() => _SearchScreenState(); +} + +class _SearchScreenState extends State { + final _controller = TextEditingController(); + final _focusNode = FocusNode(); + Timer? _debounce; + int _seq = 0; + int? _accountId; + + bool _loading = false; + PhoneLookupResult? _phoneResult; + List> _contacts = const []; + List _chats = const []; + List> _messages = const []; + List _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 _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 >[]) + : AppDatabase.searchContacts(accountId, query), + ChatsModule.searchChats(api, query), + accountId == null + ? Future.value(const >[]) + : AppDatabase.searchMessages(accountId, query), + ChatsModule.searchPublic(api, query), + phoneQuery == null + ? Future.value(null) + : ContactsModule.findByPhone(api, phoneQuery), + ]); + + if (!mounted || token != _seq) return; + + final chats = results[1] as List; + final chatIds = chats.map((c) => c.id).toSet(); + final public = (results[3] as List) + .where((c) => !chatIds.contains(c.id)) + .toList(); + + setState(() { + _phoneResult = results[4] as PhoneLookupResult?; + _contacts = results[0] as List>; + _chats = chats; + _messages = results[2] as List>; + _public = public; + _loading = false; + }); + } + + String _contactName(Map 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 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, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +}