Files
Qlyra/lib/frontend/screens/chats/search_screen.dart
T

367 lines
11 KiB
Dart

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,
),
),
],
],
),
),
],
),
),
);
}
}