Files
sevenhill f74057ce9b
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Rebrand application as Qlyra
2026-08-30 13:16:17 +03:00

638 lines
19 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/models/chat_folder.dart';
import '../../../backend/modules/chats.dart';
import '../../../backend/modules/cloud_storage.dart';
import '../../../backend/modules/folders.dart';
import '../../../backend/modules/messages.dart';
import '../../../core/protocol/packet.dart';
import '../../../core/storage/token_storage.dart';
import '../../../core/utils/haptics.dart';
import '../../../main.dart';
import '../../widgets/confirm_dialog.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/qlyra_avatar.dart';
import '../../widgets/sheet_helpers.dart';
import '../../widgets/small_spinner.dart';
typedef _ChatType = ({int filter, IconData icon, String label});
const List<_ChatType> _chatTypes = [
(filter: FolderFilter.contact, icon: Symbols.person, label: 'Контакты'),
(
filter: FolderFilter.notContact,
icon: Symbols.person_off,
label: 'Не в контактах',
),
(filter: FolderFilter.chat, icon: Symbols.group, label: 'Группы'),
(filter: FolderFilter.channel, icon: Symbols.campaign, label: 'Каналы'),
(filter: FolderFilter.bot, icon: Symbols.smart_toy, label: 'Боты'),
];
const Set<int> _editableFilters = {
FolderFilter.contact,
FolderFilter.notContact,
FolderFilter.chat,
FolderFilter.channel,
FolderFilter.bot,
FolderFilter.unread,
FolderFilter.notMuted,
};
Future<void> showFolderEditSheet(
BuildContext context, {
ChatFolder? folder,
}) async {
final cs = Theme.of(context).colorScheme;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: cs.surfaceContainerHigh,
shape: kSheetShape,
builder: (_) => _FolderEditSheet(folder: folder),
);
}
class _FolderEditSheet extends StatefulWidget {
final ChatFolder? folder;
const _FolderEditSheet({this.folder});
@override
State<_FolderEditSheet> createState() => _FolderEditSheetState();
}
class _FolderEditSheetState extends State<_FolderEditSheet> {
final TextEditingController _title = TextEditingController();
final TextEditingController _search = TextEditingController();
final Set<int> _types = {};
final Set<int> _chatIds = {};
List<int> _preservedFilters = const [];
bool _onlyUnread = false;
bool _onlyNotMuted = false;
List<CachedChat> _chats = [];
int _myId = 0;
bool _loading = true;
bool _busy = false;
bool get _isNew => widget.folder == null;
@override
void initState() {
super.initState();
final folder = widget.folder;
if (folder != null) {
_title.text = folder.title;
_types.addAll(
folder.filters.where((f) => _chatTypes.any((t) => t.filter == f)),
);
_chatIds.addAll(folder.include);
_onlyUnread = folder.filters.contains(FolderFilter.unread);
_onlyNotMuted = folder.filters.contains(FolderFilter.notMuted);
_preservedFilters = folder.filters
.where((f) => !_editableFilters.contains(f))
.toList();
}
_load();
}
@override
void dispose() {
_title.dispose();
_search.dispose();
super.dispose();
}
Future<void> _load() async {
try {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) {
if (mounted) setState(() => _loading = false);
return;
}
final list = await chats.getChats(accountId);
list.removeWhere(CloudStorageModule.isCloudStorageGroup);
if (!mounted) return;
setState(() {
_myId = accountId;
_chats = list;
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
int _peerId(CachedChat chat) {
for (final entry in chat.participants.entries) {
if (entry.key != _myId) return entry.key;
}
return _myId;
}
String _chatTitle(CachedChat chat) {
if (chat.id == 0) return 'Избранное';
if (chat.type == 'DIALOG') {
return ContactCache.get(_peerId(chat)) ?? chat.title ?? 'Пользователь';
}
return chat.title ?? 'Чат';
}
String? _chatAvatar(CachedChat chat) {
if (chat.type == 'DIALOG' && chat.id != 0) {
return ContactCache.getAvatar(_peerId(chat)) ?? chat.iconUrl;
}
return chat.iconUrl;
}
List<int> _buildFilters() => [
..._types,
if (_onlyUnread) FolderFilter.unread,
if (_onlyNotMuted) FolderFilter.notMuted,
..._preservedFilters,
];
bool get _canSubmit =>
!_busy &&
_title.text.trim().isNotEmpty &&
(_types.isNotEmpty || _chatIds.isNotEmpty);
Future<void> _submit() async {
if (!_canSubmit) return;
if (_myId == 0) {
showCustomNotification(context, 'Нет активного аккаунта');
return;
}
setState(() => _busy = true);
final navigator = Navigator.of(context);
try {
final title = _title.text.trim();
final folder = widget.folder;
if (folder == null) {
await FoldersModule.createFolder(
api,
_myId,
title: title,
include: _chatIds.toList(),
filters: _buildFilters(),
);
} else {
await FoldersModule.updateFolder(
api,
_myId,
folder,
title: title,
include: _chatIds.toList(),
filters: _buildFilters(),
);
}
Haptics.success();
if (mounted) navigator.pop();
} catch (e) {
Haptics.error();
if (!mounted) return;
setState(() => _busy = false);
showCustomNotification(
context,
e is PacketError ? e.message : 'Не удалось сохранить папку',
);
}
}
Future<void> _delete() async {
final folder = widget.folder;
if (folder == null || _busy) return;
if (_myId == 0) {
showCustomNotification(context, 'Нет активного аккаунта');
return;
}
final confirmed = await showConfirmDialog(
context,
message: 'Удалить папку «${folder.title}»? Чаты останутся на месте.',
confirmLabel: 'Удалить',
destructive: true,
);
if (!confirmed || !mounted) return;
setState(() => _busy = true);
final navigator = Navigator.of(context);
try {
await FoldersModule.deleteFolders(api, _myId, [folder.id]);
Haptics.success();
if (mounted) navigator.pop();
} catch (e) {
Haptics.error();
if (!mounted) return;
setState(() => _busy = false);
showCustomNotification(
context,
e is PacketError ? e.message : 'Не удалось удалить папку',
);
}
}
void _clearSelection() {
setState(() {
_types.clear();
_chatIds.clear();
_onlyUnread = false;
_onlyNotMuted = false;
});
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final viewInsets = MediaQuery.of(context).viewInsets;
final query = _search.text.trim().toLowerCase();
final types = query.isEmpty
? _chatTypes
: _chatTypes
.where((t) => t.label.toLowerCase().contains(query))
.toList();
final visibleChats = query.isEmpty
? _chats
: _chats
.where((c) => _chatTitle(c).toLowerCase().contains(query))
.toList();
return Padding(
padding: EdgeInsets.only(bottom: viewInsets.bottom),
child: SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.9,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(cs),
Flexible(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
physics: const BouncingScrollPhysics(),
children: [
_buildTitleCard(cs),
const SizedBox(height: 12),
_buildPickerCard(cs, types, visibleChats),
if (widget.folder?.canEditFilters ?? true) ...[
const SizedBox(height: 12),
_buildShowOnlyCard(cs),
],
],
),
),
_buildActions(cs),
],
),
),
),
);
}
Widget _buildHeader(ColorScheme cs) => Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 8, 4),
child: Row(
children: [
Expanded(
child: Text(
_isNew ? 'Новая папка' : 'Изменение папки',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
onPressed: _busy ? null : () => Navigator.pop(context),
icon: Icon(Symbols.close, color: cs.onSurfaceVariant),
),
],
),
);
Widget _buildCard(ColorScheme cs, Widget child) => Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
clipBehavior: Clip.antiAlias,
child: child,
);
Widget _buildTitleCard(ColorScheme cs) {
final canEditTitle = widget.folder?.canEditTitle ?? true;
return _buildCard(
cs,
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
child: Row(
children: [
Expanded(
child: TextField(
controller: _title,
enabled: canEditTitle && !_busy,
onChanged: (_) => setState(() {}),
maxLength: FoldersModule.titleMaxLength,
inputFormatters: [
LengthLimitingTextInputFormatter(
FoldersModule.titleMaxLength,
),
],
style: TextStyle(color: cs.onSurface, fontSize: 16),
decoration: InputDecoration(
hintText: 'Название папки',
hintStyle: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 16,
),
border: InputBorder.none,
counterText: '',
),
),
),
const SizedBox(width: 12),
Text(
'${_title.text.characters.length}/${FoldersModule.titleMaxLength}',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
],
),
),
);
}
Widget _buildPickerCard(
ColorScheme cs,
List<_ChatType> types,
List<CachedChat> visibleChats,
) {
final canEditFilters = widget.folder?.canEditFilters ?? true;
return _buildCard(
cs,
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
child: TextField(
controller: _search,
onChanged: (_) => setState(() {}),
style: TextStyle(color: cs.onSurface, fontSize: 14),
decoration: InputDecoration(
hintText: 'Найти по имени',
hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
prefixIcon: Icon(
Symbols.search,
color: cs.onSurfaceVariant,
size: 20,
),
prefixIconConstraints: const BoxConstraints(
minWidth: 48,
minHeight: 0,
),
isDense: true,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
if (types.isNotEmpty && canEditFilters) ...[
_buildSectionLabel(cs, 'ТИПЫ ЧАТОВ'),
for (final type in types)
_buildRow(
cs,
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
shape: BoxShape.circle,
),
child: Icon(type.icon, color: cs.onSurface, size: 20),
),
title: type.label,
selected: _types.contains(type.filter),
onTap: () => setState(() {
if (!_types.remove(type.filter)) _types.add(type.filter);
Haptics.selection();
}),
),
],
if (_loading) ...[
_buildSectionLabel(cs, 'ЧАТЫ И КАНАЛЫ'),
const Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Center(child: SmallSpinner(size: 28)),
),
] else if (visibleChats.isNotEmpty) ...[
_buildSectionLabel(cs, 'ЧАТЫ И КАНАЛЫ'),
for (final chat in visibleChats)
_buildRow(
cs,
leading: QlyraAvatar(
name: _chatTitle(chat),
size: 40,
imageUrl: _chatAvatar(chat),
),
title: _chatTitle(chat),
subtitle: chat.id == 0 ? 'Сообщения себе' : null,
selected: _chatIds.contains(chat.id),
onTap: () => setState(() {
if (!_chatIds.remove(chat.id)) _chatIds.add(chat.id);
Haptics.selection();
}),
),
],
if (!_loading && types.isEmpty && visibleChats.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
child: Text(
'Ничего не найдено',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
),
),
],
),
);
}
Widget _buildSectionLabel(ColorScheme cs, String text) => Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 6),
child: Text(
text,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
),
),
);
Widget _buildRow(
ColorScheme cs, {
required Widget leading,
required String title,
String? subtitle,
required bool selected,
required VoidCallback onTap,
}) => InkWell(
onTap: _busy ? null : onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: [
leading,
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
color: cs.onSurface,
fontSize: 15,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
color: cs.onSurfaceVariant.withValues(alpha: 0.8),
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
AnimatedScale(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOutBack,
scale: selected ? 1 : 0,
child: Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: cs.primary,
shape: BoxShape.circle,
),
child: Icon(Symbols.check, color: cs.onPrimary, size: 16),
),
),
],
),
),
);
Widget _buildShowOnlyCard(ColorScheme cs) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSectionLabel(cs, 'ПОКАЗЫВАТЬ ТОЛЬКО'),
_buildCard(
cs,
Column(
children: [
_buildToggle(
cs,
icon: Symbols.notifications,
title: 'Чаты с уведомлениями',
value: _onlyNotMuted,
onChanged: (v) => setState(() => _onlyNotMuted = v),
),
_buildToggle(
cs,
icon: Symbols.mark_chat_unread,
title: 'Непрочитанные чаты',
value: _onlyUnread,
onChanged: (v) => setState(() => _onlyUnread = v),
),
],
),
),
],
);
Widget _buildToggle(
ColorScheme cs, {
required IconData icon,
required String title,
required bool value,
required ValueChanged<bool> onChanged,
}) => Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 8, 4),
child: Row(
children: [
Icon(icon, color: cs.onSurfaceVariant, size: 22),
const SizedBox(width: 16),
Expanded(
child: Text(
title,
style: TextStyle(color: cs.onSurface, fontSize: 15),
),
),
Switch(
value: value,
onChanged: _busy
? null
: (v) {
Haptics.selection();
onChanged(v);
},
),
],
),
);
Widget _buildActions(ColorScheme cs) {
final folder = widget.folder;
final hasSelection =
_types.isNotEmpty ||
_chatIds.isNotEmpty ||
_onlyUnread ||
_onlyNotMuted;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Row(
children: [
Expanded(
child: folder == null
? SheetButton(
label: 'Очистить выбор',
filled: false,
onTap: hasSelection && !_busy ? _clearSelection : null,
)
: SheetButton(
label: 'Удалить папку',
filled: false,
color: cs.error,
onTap: folder.canDelete && !_busy ? _delete : null,
),
),
const SizedBox(width: 12),
Expanded(
child: SheetButton(
label: _isNew ? 'Создать папку' : 'Сохранить',
filled: true,
onTap: _canSubmit ? _submit : null,
),
),
],
),
);
}
}