From 50acd58a9ef6354f47791e516bc966176e779b67 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 25 Jul 2026 16:12:17 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=B0.=20=D0=92=D0=BE=D0=B7?= =?UTF-8?q?=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BF=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=D1=82.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 37 ++- .../screens/chats/chat_list_screen.dart | 10 +- .../screens/chats/create_channel_flow.dart | 275 ++++++++++++++++++ 3 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 lib/frontend/screens/chats/create_channel_flow.dart diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 2dcaaca..847377f 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -1435,6 +1435,33 @@ class ChatsModule { required String title, required List userIds, bool notify = true, + }) => _createChat( + api, + chatType: 'CHAT', + title: title, + userIds: userIds, + notify: notify, + ); + + Future createChannel( + Api api, { + required String title, + List userIds = const [], + bool notify = true, + }) => _createChat( + api, + chatType: 'CHANNEL', + title: title, + userIds: userIds, + notify: notify, + ); + + Future _createChat( + Api api, { + required String chatType, + required String title, + required List userIds, + required bool notify, }) async { final payload = { 'message': { @@ -1443,7 +1470,7 @@ class ChatsModule { { '_type': 'CONTROL', 'event': 'new', - 'chatType': 'CHAT', + 'chatType': chatType, 'title': title, 'userIds': userIds, }, @@ -1453,22 +1480,22 @@ class ChatsModule { }; final packet = await api.sendRequest(Opcode.msgSend, payload); if (!packet.isOk) { - logger.w('createGroupChat: server error payload=${packet.payload}'); + logger.w('_createChat($chatType): server error payload=${packet.payload}'); return null; } final data = packet.payload; if (data is! Map) { - logger.w('createGroupChat: payload is not a Map: $data'); + logger.w('_createChat($chatType): payload is not a Map: $data'); return null; } final chat = data['chat']; if (chat is! Map) { - logger.w('createGroupChat: response has no chat field: $data'); + logger.w('_createChat($chatType): response has no chat field: $data'); return null; } final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) { - logger.w('createGroupChat: no active account id'); + logger.w('_createChat($chatType): no active account id'); return null; } return cacheServerChat(chat, accountId); diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index ba1e3a3..bbdd752 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -8,6 +8,7 @@ import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'chat_screen.dart'; import 'search_screen.dart'; +import 'create_channel_flow.dart'; import 'create_group_flow.dart'; import '../contacts/add_contact_sheet.dart'; import '../../widgets/adaptive_shell.dart'; @@ -2838,7 +2839,14 @@ class _ChatListScreenState extends State }, ), const SizedBox(height: 4), - _buildFabMenuItem(Symbols.campaign, 'Создать канал'), + _buildFabMenuItem( + Symbols.campaign, + 'Создать канал', + onTap: () { + _toggleFab(); + showCreateChannelFlow(context); + }, + ), const SizedBox(height: 4), _buildFabMenuItem( Symbols.person_add, diff --git a/lib/frontend/screens/chats/create_channel_flow.dart b/lib/frontend/screens/chats/create_channel_flow.dart new file mode 100644 index 0000000..c4e7438 --- /dev/null +++ b/lib/frontend/screens/chats/create_channel_flow.dart @@ -0,0 +1,275 @@ +import 'dart:io'; + +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 '../../../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 showCreateChannelFlow(BuildContext context) async { + final cs = Theme.of(context).colorScheme; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: kSheetShape, + builder: (_) => const _CreateChannelFlow(), + ); +} + +class _CreateChannelFlow extends StatefulWidget { + const _CreateChannelFlow(); + + @override + State<_CreateChannelFlow> createState() => _CreateChannelFlowState(); +} + +class _CreateChannelFlowState extends State<_CreateChannelFlow> { + final TextEditingController _title = TextEditingController(); + File? _avatar; + bool _creating = false; + + @override + void dispose() { + _title.dispose(); + super.dispose(); + } + + Future _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 _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 chats.createChannel(api, title: title); + if (!mounted) return; + if (chat == null) { + showCustomNotification(context, 'Не удалось создать канал'); + setState(() => _creating = false); + return; + } + + if (_avatar != null) { + final url = await chats.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 chats.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); + } + } + } + + @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(20, 16, 8, 4), + child: Row( + children: [ + 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: 52, + height: 52, + 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: 22, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _title, + onChanged: (_) => setState(() {}), + enabled: !_creating, + autofocus: true, + 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, 8, 16, 8), + child: Text( + 'В канале публикуете только вы, участники читают. Пригласить их можно после создания.', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( + children: [ + Expanded( + child: _PillButton( + label: 'Отменить', + filled: false, + onTap: _creating ? null : () => Navigator.pop(context), + cs: cs, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _PillButton( + label: _creating ? 'Создаю...' : 'Создать', + filled: true, + onTap: canCreate ? _create : null, + cs: cs, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _PillButton extends StatelessWidget { + final String label; + final bool filled; + final VoidCallback? onTap; + final ColorScheme cs; + const _PillButton({ + 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, + ), + ), + ), + ); + } +}