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

640 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/chats.dart';
import '../../../backend/modules/contacts.dart';
import '../../../core/storage/token_storage.dart';
import '../../../core/utils/image_utils.dart';
import '../../../main.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/sheet_helpers.dart';
import '../../widgets/swipe_route.dart';
import 'chat_screen.dart';
Future<void> showCreateGroupFlow(BuildContext context) async {
final cs = Theme.of(context).colorScheme;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: cs.surfaceContainerHigh,
shape: kSheetShape,
builder: (_) => const _CreateGroupFlow(),
);
}
enum _Step { pickParticipants, groupDetails }
class _CreateGroupFlow extends StatefulWidget {
const _CreateGroupFlow();
@override
State<_CreateGroupFlow> createState() => _CreateGroupFlowState();
}
class _CreateGroupFlowState extends State<_CreateGroupFlow> {
_Step _step = _Step.pickParticipants;
List<CachedContact> _all = [];
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();
_loadContacts();
}
@override
void dispose() {
_search.dispose();
_title.dispose();
super.dispose();
}
Future<void> _loadContacts() async {
try {
final myId = await TokenStorage.getActiveAccountId();
if (myId == null) {
if (mounted) setState(() => _loading = false);
return;
}
final list = await ContactsModule.getContacts(myId);
list.removeWhere((c) => c.id == myId);
list.sort(
(a, b) => _displayName(
a,
).toLowerCase().compareTo(_displayName(b).toLowerCase()),
);
if (!mounted) return;
setState(() {
_all = list;
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
void _toggle(CachedContact c) {
setState(() {
final idx = _selected.indexWhere((x) => x.id == c.id);
if (idx >= 0) {
_selected.removeAt(idx);
} else {
_selected.add(c);
}
});
}
bool _isSelected(int id) => _selected.any((c) => c.id == id);
Future<void> _pickAvatar() async {
if (_creating) return;
final result = await FilePicker.platform.pickFiles(type: FileType.image);
if (result == null || result.files.isEmpty) return;
final path = result.files.first.path;
if (path == null) return;
final file = File(path);
final size = await file.length();
if (size > kMaxAvatarBytes) {
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: _selected.map((c) => c.id).toList(),
);
if (!mounted) return;
if (chat == null) {
showCustomNotification(context, 'Не удалось создать группу');
setState(() => _creating = false);
return;
}
if (_avatar != null) {
final url = await ChatsModule.requestChatPhotoUploadUrl(api);
if (url != null) {
final bytes = await compressAvatar(await _avatar!.readAsBytes());
if (bytes == null) {
if (mounted) {
showCustomNotification(context, 'Не удалось обработать аватарку');
}
} else {
final token = await fileUploader.uploadImage(
Uri.parse(url),
bytes,
filename: 'avatar.jpg',
);
if (token != null) {
await ChatsModule.setChatPhoto(
api,
chatId: chat.id,
photoToken: token,
);
} else if (mounted) {
showCustomNotification(context, 'Не удалось загрузить аватарку');
}
}
}
}
if (!mounted) return;
navigator.pop();
navigator.push(
SwipeRoute(
builder: (_) => ChatScreen(
chatId: chat.id,
name: chat.title ?? title,
imageUrl: chat.iconUrl ?? '',
chatType: chat.type,
),
),
);
} catch (e) {
if (mounted) {
showCustomNotification(context, 'Ошибка: $e');
setState(() => _creating = false);
}
}
}
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 viewInsets = MediaQuery.of(context).viewInsets;
return Padding(
padding: EdgeInsets.only(bottom: viewInsets.bottom),
child: SafeArea(
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(),
),
),
),
),
);
}
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 {
final CachedContact contact;
final double size;
final ColorScheme cs;
const _Avatar({required this.contact, required this.size, required this.cs});
@override
Widget build(BuildContext context) {
final url = contact.baseUrl;
if (url != null && url.isNotEmpty) {
return ClipOval(
child: CachedNetworkImage(
imageUrl: url,
width: size,
height: size,
fit: BoxFit.cover,
placeholder: (_, _) => _initials(cs, size),
errorWidget: (_, _, _) => _initials(cs, size),
),
);
}
return _initials(cs, size);
}
Widget _initials(ColorScheme cs, double size) {
final initial = contact.firstName.isNotEmpty
? contact.firstName[0].toUpperCase()
: '?';
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: cs.primaryContainer,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
initial,
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: size * 0.4,
fontWeight: FontWeight.w600,
),
),
);
}
}
class _SelectedChip extends StatelessWidget {
final CachedContact contact;
final String label;
final VoidCallback onRemove;
final ColorScheme cs;
const _SelectedChip({
required this.contact,
required this.label,
required this.onRemove,
required this.cs,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onRemove,
child: Container(
padding: const EdgeInsets.fromLTRB(4, 4, 12, 4),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_Avatar(contact: contact, size: 24, cs: cs),
const SizedBox(width: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 140),
child: Text(
label,
style: TextStyle(color: cs.onSurface, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}
class _SheetButton extends StatelessWidget {
final String label;
final bool filled;
final VoidCallback? onTap;
final ColorScheme cs;
const _SheetButton({
required this.label,
required this.filled,
required this.onTap,
required this.cs,
});
@override
Widget build(BuildContext context) {
final disabled = onTap == null;
return GestureDetector(
onTap: onTap,
child: Container(
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: filled
? (disabled ? cs.primary.withValues(alpha: 0.4) : cs.primary)
: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(22),
),
child: Text(
label,
style: TextStyle(
color: filled
? cs.onPrimary
: (disabled
? cs.onSurface.withValues(alpha: 0.4)
: cs.onSurface),
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
);
}
}