удаление чатов и фикс загрузки ботов в chatsэ
This commit is contained in:
@@ -8,6 +8,7 @@ import 'dart:ui' as ui;
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'create_group_flow.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
import '../calls/calls_tab.dart';
|
||||
import '../contacts/contacts_tab.dart';
|
||||
@@ -61,6 +62,8 @@ class ChatListScreen extends StatefulWidget {
|
||||
State<ChatListScreen> createState() => _ChatListScreenState();
|
||||
}
|
||||
|
||||
enum _DeleteKind { personalLike, ownerGroup, blocked }
|
||||
|
||||
class _ChatListScreenState extends State<ChatListScreen>
|
||||
with TickerProviderStateMixin {
|
||||
String? _selectedFolderId;
|
||||
@@ -168,6 +171,176 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
});
|
||||
}
|
||||
|
||||
List<CachedChat> _selectedChatObjects() {
|
||||
if (_selectedChats.isEmpty) return const [];
|
||||
final ids = _selectedChats;
|
||||
return _chats.where((c) => ids.contains(c.id.toString())).toList();
|
||||
}
|
||||
|
||||
_DeleteKind _categorizeChat(CachedChat c, int myId) {
|
||||
if (c.type == 'DIALOG') return _DeleteKind.personalLike;
|
||||
if (c.iAmAdmin(myId)) return _DeleteKind.ownerGroup;
|
||||
return _DeleteKind.blocked;
|
||||
}
|
||||
|
||||
_DeleteKind? _selectionDeleteCategory() {
|
||||
if (_sessionState != SessionState.online) return null;
|
||||
final myId = _profile?.id;
|
||||
if (myId == null) return null;
|
||||
final selected = _selectedChatObjects();
|
||||
if (selected.isEmpty) return null;
|
||||
final cats = selected.map((c) => _categorizeChat(c, myId)).toSet();
|
||||
if (cats.contains(_DeleteKind.blocked)) return null;
|
||||
if (cats.length > 1) return null;
|
||||
return cats.single;
|
||||
}
|
||||
|
||||
Future<void> _onDeleteTap() async {
|
||||
final selectedBefore = _selectedChatObjects();
|
||||
if (selectedBefore.isEmpty) return;
|
||||
final myId = _profile?.id;
|
||||
if (myId == null) return;
|
||||
|
||||
await ChatsModule.refreshChats(api, selectedBefore.map((c) => c.id).toList());
|
||||
if (!mounted) return;
|
||||
|
||||
final selectedAfter = _selectedChatObjects();
|
||||
if (selectedAfter.isEmpty) return;
|
||||
final cats = selectedAfter.map((c) => _categorizeChat(c, myId)).toSet();
|
||||
if (cats.contains(_DeleteKind.blocked) || cats.length > 1) {
|
||||
showCustomNotification(context, 'Статус чатов изменился, попробуйте ещё раз');
|
||||
return;
|
||||
}
|
||||
final kind = cats.single;
|
||||
|
||||
final confirmed = await _showDeleteConfirmDialog(selectedAfter, kind);
|
||||
if (!mounted || confirmed != true) return;
|
||||
|
||||
final errors = <String>[];
|
||||
for (final c in selectedAfter) {
|
||||
final forAll = kind == _DeleteKind.ownerGroup;
|
||||
final err = await ChatsModule.deleteChat(
|
||||
api,
|
||||
chatId: c.id,
|
||||
lastEventTime: c.lastEventTime,
|
||||
forAll: forAll,
|
||||
);
|
||||
if (err != null) errors.add(err);
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (errors.isNotEmpty) {
|
||||
final msg = errors.length == 1
|
||||
? errors.first
|
||||
: 'Не удалось удалить ${errors.length} чат(ов): ${errors.first}';
|
||||
showCustomNotification(context, msg);
|
||||
}
|
||||
_clearSelection();
|
||||
}
|
||||
|
||||
Future<bool?> _showDeleteConfirmDialog(
|
||||
List<CachedChat> selected,
|
||||
_DeleteKind kind,
|
||||
) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final count = selected.length;
|
||||
final single = count == 1 ? selected.first : null;
|
||||
|
||||
String title;
|
||||
String body;
|
||||
String primaryLabel;
|
||||
switch (kind) {
|
||||
case _DeleteKind.personalLike:
|
||||
title = single != null
|
||||
? 'Удалить чат с ${single.title ?? ''}?'
|
||||
: 'Удалить $count чатов?';
|
||||
body = 'Восстановить переписку не получится';
|
||||
primaryLabel = count == 1 ? 'Удалить чат' : 'Удалить';
|
||||
case _DeleteKind.ownerGroup:
|
||||
title = single != null
|
||||
? 'Хотите удалить чат «${single.title ?? ''}»?'
|
||||
: 'Удалить $count групп у всех?';
|
||||
body = single != null
|
||||
? 'Передайте права владельца, чтобы остальные участники могли продолжить общение'
|
||||
: 'Действие нельзя отменить';
|
||||
primaryLabel = count == 1 ? 'Удалить чат у всех' : 'Удалить у всех';
|
||||
case _DeleteKind.blocked:
|
||||
return Future.value(false);
|
||||
}
|
||||
|
||||
return showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (ctx) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
body,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (kind == _DeleteKind.ownerGroup && single != null) ...[
|
||||
Container(
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: Text(
|
||||
'Передать права и выйти',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface.withValues(alpha: 0.4),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(ctx, true),
|
||||
child: Container(
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.error,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: Text(
|
||||
primaryLabel,
|
||||
style: TextStyle(
|
||||
color: cs.onError,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _isInitialLoading = true;
|
||||
DateTime _storiesLockdownUntil = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
@@ -393,7 +566,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
if (!mounted) return;
|
||||
_contactRebuildTimer?.cancel();
|
||||
_contactRebuildTimer = Timer(const Duration(milliseconds: 120), () {
|
||||
if (mounted) setState(() {});
|
||||
if (!mounted) return;
|
||||
_cachedChatsBody = null;
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1644,10 +1819,11 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Symbols.delete, color: cs.onSurface),
|
||||
onPressed: () {},
|
||||
),
|
||||
if (_selectionDeleteCategory() != null)
|
||||
IconButton(
|
||||
icon: Icon(Symbols.delete, color: cs.onSurface),
|
||||
onPressed: _onDeleteTap,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Symbols.archive, color: cs.onSurface),
|
||||
onPressed: () {},
|
||||
|
||||
@@ -12,19 +12,10 @@ import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import 'chat_screen.dart';
|
||||
|
||||
const int _maxAvatarBytes = 8 * 1024 * 1024;
|
||||
|
||||
Future<void> showCreateGroupFlow(BuildContext context) async {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final selected = await showModalBottomSheet<List<CachedContact>>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => const _ParticipantsPickerSheet(),
|
||||
);
|
||||
if (selected == null) return;
|
||||
if (!context.mounted) return;
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
@@ -32,30 +23,43 @@ Future<void> showCreateGroupFlow(BuildContext context) async {
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => _GroupDetailsSheet(participants: selected),
|
||||
builder: (_) => const _CreateGroupFlow(),
|
||||
);
|
||||
}
|
||||
|
||||
class _ParticipantsPickerSheet extends StatefulWidget {
|
||||
const _ParticipantsPickerSheet();
|
||||
enum _Step { pickParticipants, groupDetails }
|
||||
|
||||
class _CreateGroupFlow extends StatefulWidget {
|
||||
const _CreateGroupFlow();
|
||||
|
||||
@override
|
||||
State<_ParticipantsPickerSheet> createState() => _ParticipantsPickerSheetState();
|
||||
State<_CreateGroupFlow> createState() => _CreateGroupFlowState();
|
||||
}
|
||||
|
||||
class _ParticipantsPickerSheetState extends State<_ParticipantsPickerSheet> {
|
||||
final TextEditingController _search = TextEditingController();
|
||||
class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
_Step _step = _Step.pickParticipants;
|
||||
List<CachedContact> _all = [];
|
||||
final Set<int> _selectedIds = {};
|
||||
final List<CachedContact> _selected = [];
|
||||
bool _loading = true;
|
||||
final TextEditingController _search = TextEditingController();
|
||||
final TextEditingController _title = TextEditingController();
|
||||
File? _avatar;
|
||||
bool _creating = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
_loadContacts();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
@override
|
||||
void dispose() {
|
||||
_search.dispose();
|
||||
_title.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadContacts() async {
|
||||
try {
|
||||
final myId = await TokenStorage.getActiveAccountId();
|
||||
if (myId == null) {
|
||||
@@ -75,223 +79,18 @@ class _ParticipantsPickerSheetState extends State<_ParticipantsPickerSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
String _displayName(CachedContact c) {
|
||||
final last = c.lastName ?? '';
|
||||
return last.isEmpty ? c.firstName : '${c.firstName} $last';
|
||||
void _toggle(CachedContact c) {
|
||||
setState(() {
|
||||
final idx = _selected.indexWhere((x) => x.id == c.id);
|
||||
if (idx >= 0) {
|
||||
_selected.removeAt(idx);
|
||||
} else {
|
||||
_selected.add(c);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String _statusText(CachedContact c) {
|
||||
if (c.isBot) return 'Бот';
|
||||
return 'Был(-а) недавно';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_search.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final viewInsets = MediaQuery.of(context).viewInsets;
|
||||
final query = _search.text.trim().toLowerCase();
|
||||
final filtered = query.isEmpty
|
||||
? _all
|
||||
: _all.where((c) => _displayName(c).toLowerCase().contains(query)).toList();
|
||||
final selected = _all.where((c) => _selectedIds.contains(c.id)).toList();
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: viewInsets.bottom),
|
||||
child: SafeArea(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.85),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Выберите участников',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: Icon(Symbols.close, color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (selected.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final c in selected)
|
||||
_SelectedChip(
|
||||
contact: c,
|
||||
label: _displayName(c),
|
||||
onRemove: () => setState(() => _selectedIds.remove(c.id)),
|
||||
cs: cs,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
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),
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: _loading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final c = filtered[i];
|
||||
final picked = _selectedIds.contains(c.id);
|
||||
final dim = c.isBot;
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (picked) {
|
||||
_selectedIds.remove(c.id);
|
||||
} else {
|
||||
_selectedIds.add(c.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_Avatar(contact: c, size: 40, cs: cs),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_displayName(c),
|
||||
style: TextStyle(
|
||||
color: dim
|
||||
? cs.onSurface.withValues(alpha: 0.5)
|
||||
: cs.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_statusText(c),
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.8),
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (picked)
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Symbols.check, color: cs.onPrimary, size: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: 'Отменить',
|
||||
filled: false,
|
||||
onTap: () => Navigator.pop(context),
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: 'Далее',
|
||||
filled: true,
|
||||
onTap: () {
|
||||
final picked = _all
|
||||
.where((c) => _selectedIds.contains(c.id))
|
||||
.toList();
|
||||
Navigator.pop(context, picked);
|
||||
},
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GroupDetailsSheet extends StatefulWidget {
|
||||
final List<CachedContact> participants;
|
||||
const _GroupDetailsSheet({required this.participants});
|
||||
|
||||
@override
|
||||
State<_GroupDetailsSheet> createState() => _GroupDetailsSheetState();
|
||||
}
|
||||
|
||||
class _GroupDetailsSheetState extends State<_GroupDetailsSheet> {
|
||||
final TextEditingController _title = TextEditingController();
|
||||
File? _avatar;
|
||||
bool _creating = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
bool _isSelected(int id) => _selected.any((c) => c.id == id);
|
||||
|
||||
Future<void> _pickAvatar() async {
|
||||
if (_creating) return;
|
||||
@@ -299,18 +98,27 @@ class _GroupDetailsSheetState extends State<_GroupDetailsSheet> {
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final path = result.files.first.path;
|
||||
if (path == null) return;
|
||||
setState(() => _avatar = File(path));
|
||||
final file = File(path);
|
||||
final size = await file.length();
|
||||
if (size > _maxAvatarBytes) {
|
||||
if (!mounted) return;
|
||||
showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)');
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _avatar = file);
|
||||
}
|
||||
|
||||
Future<void> _create() async {
|
||||
final title = _title.text.trim();
|
||||
if (title.isEmpty || _creating) return;
|
||||
setState(() => _creating = true);
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
try {
|
||||
final chat = await ChatsModule.createGroupChat(
|
||||
api,
|
||||
title: title,
|
||||
userIds: widget.participants.map((c) => c.id).toList(),
|
||||
userIds: _selected.map((c) => c.id).toList(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (chat == null) {
|
||||
@@ -323,17 +131,22 @@ class _GroupDetailsSheetState extends State<_GroupDetailsSheet> {
|
||||
final url = await ChatsModule.requestChatPhotoUploadUrl(api);
|
||||
if (url != null) {
|
||||
final bytes = await _avatar!.readAsBytes();
|
||||
final token = await fileUploader.uploadImage(Uri.parse(url), bytes);
|
||||
final token = await fileUploader.uploadImage(
|
||||
Uri.parse(url),
|
||||
bytes,
|
||||
filename: _avatar!.uri.pathSegments.last,
|
||||
);
|
||||
if (token != null) {
|
||||
await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token);
|
||||
} else if (mounted) {
|
||||
showCustomNotification(context, 'Не удалось загрузить аватарку');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
context,
|
||||
navigator.pop();
|
||||
navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChatScreen(
|
||||
chatId: chat.id,
|
||||
@@ -351,113 +164,304 @@ class _GroupDetailsSheetState extends State<_GroupDetailsSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
String _displayName(CachedContact c) {
|
||||
final last = c.lastName ?? '';
|
||||
return last.isEmpty ? c.firstName : '${c.firstName} $last';
|
||||
}
|
||||
|
||||
String _statusText(CachedContact c) => c.isBot ? 'Бот' : 'Был(-а) недавно';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final viewInsets = MediaQuery.of(context).viewInsets;
|
||||
final canCreate = _title.text.trim().isNotEmpty && !_creating;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: viewInsets.bottom),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _creating ? null : () => Navigator.pop(context),
|
||||
icon: Icon(Symbols.arrow_back, color: cs.onSurfaceVariant),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.85),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
transitionBuilder: (child, anim) {
|
||||
final offset = child.key == const ValueKey(_Step.pickParticipants)
|
||||
? Offset(-0.05, 0)
|
||||
: Offset(0.05, 0);
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(begin: offset, end: Offset.zero).animate(anim),
|
||||
child: FadeTransition(opacity: anim, child: child),
|
||||
);
|
||||
},
|
||||
child: _step == _Step.pickParticipants
|
||||
? KeyedSubtree(
|
||||
key: const ValueKey(_Step.pickParticipants),
|
||||
child: _buildPickerStep(),
|
||||
)
|
||||
: KeyedSubtree(
|
||||
key: const ValueKey(_Step.groupDetails),
|
||||
child: _buildDetailsStep(),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Создать группу',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _creating
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.maybePop(context);
|
||||
},
|
||||
icon: Icon(Symbols.close, color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _pickAvatar,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _avatar != null
|
||||
? Image.file(_avatar!, fit: BoxFit.cover)
|
||||
: Icon(Symbols.add_a_photo, color: cs.onSurfaceVariant, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _title,
|
||||
onChanged: (_) => setState(() {}),
|
||||
enabled: !_creating,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Название группы',
|
||||
hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 16),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: 'Отменить',
|
||||
filled: false,
|
||||
onTap: _creating ? null : () => Navigator.pop(context),
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: _creating ? 'Создаю...' : 'Создать',
|
||||
filled: true,
|
||||
onTap: canCreate ? _create : null,
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPickerStep() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final query = _search.text.trim().toLowerCase();
|
||||
final filtered = query.isEmpty
|
||||
? _all
|
||||
: _all.where((c) => _displayName(c).toLowerCase().contains(query)).toList();
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Выберите участников',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: Icon(Symbols.close, color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_selected.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final c in _selected)
|
||||
_SelectedChip(
|
||||
contact: c,
|
||||
label: _displayName(c),
|
||||
onRemove: () => _toggle(c),
|
||||
cs: cs,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
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),
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: _loading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final c = filtered[i];
|
||||
final picked = _isSelected(c.id);
|
||||
final dim = c.isBot;
|
||||
return InkWell(
|
||||
onTap: () => _toggle(c),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_Avatar(contact: c, size: 40, cs: cs),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_displayName(c),
|
||||
style: TextStyle(
|
||||
color: dim
|
||||
? cs.onSurface.withValues(alpha: 0.5)
|
||||
: cs.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_statusText(c),
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.8),
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (picked)
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Symbols.check, color: cs.onPrimary, size: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: 'Отменить',
|
||||
filled: false,
|
||||
onTap: () => Navigator.pop(context),
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: 'Далее',
|
||||
filled: true,
|
||||
onTap: () => setState(() => _step = _Step.groupDetails),
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailsStep() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final canCreate = _title.text.trim().isNotEmpty && !_creating;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _creating
|
||||
? null
|
||||
: () => setState(() => _step = _Step.pickParticipants),
|
||||
icon: Icon(Symbols.arrow_back, color: cs.onSurfaceVariant),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Создать группу',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _creating ? null : () => Navigator.pop(context),
|
||||
icon: Icon(Symbols.close, color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _pickAvatar,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _avatar != null
|
||||
? Image.file(_avatar!, fit: BoxFit.cover)
|
||||
: Icon(Symbols.add_a_photo, color: cs.onSurfaceVariant, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _title,
|
||||
onChanged: (_) => setState(() {}),
|
||||
enabled: !_creating,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Название группы',
|
||||
hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 16),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: 'Отменить',
|
||||
filled: false,
|
||||
onTap: _creating ? null : () => Navigator.pop(context),
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: _creating ? 'Создаю...' : 'Создать',
|
||||
filled: true,
|
||||
onTap: canCreate ? _create : null,
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Avatar extends StatelessWidget {
|
||||
@@ -570,9 +574,7 @@ class _SheetButton extends StatelessWidget {
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: filled
|
||||
? (disabled
|
||||
? cs.primary.withValues(alpha: 0.4)
|
||||
: cs.primary)
|
||||
? (disabled ? cs.primary.withValues(alpha: 0.4) : cs.primary)
|
||||
: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user