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
638 lines
19 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|