feat: добавил создание канала. Возможности модерации пока нет.
This commit is contained in:
@@ -1435,6 +1435,33 @@ class ChatsModule {
|
||||
required String title,
|
||||
required List<int> userIds,
|
||||
bool notify = true,
|
||||
}) => _createChat(
|
||||
api,
|
||||
chatType: 'CHAT',
|
||||
title: title,
|
||||
userIds: userIds,
|
||||
notify: notify,
|
||||
);
|
||||
|
||||
Future<CachedChat?> createChannel(
|
||||
Api api, {
|
||||
required String title,
|
||||
List<int> userIds = const [],
|
||||
bool notify = true,
|
||||
}) => _createChat(
|
||||
api,
|
||||
chatType: 'CHANNEL',
|
||||
title: title,
|
||||
userIds: userIds,
|
||||
notify: notify,
|
||||
);
|
||||
|
||||
Future<CachedChat?> _createChat(
|
||||
Api api, {
|
||||
required String chatType,
|
||||
required String title,
|
||||
required List<int> 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);
|
||||
|
||||
@@ -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<ChatListScreen>
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildFabMenuItem(Symbols.campaign, 'Создать канал'),
|
||||
_buildFabMenuItem(
|
||||
Symbols.campaign,
|
||||
'Создать канал',
|
||||
onTap: () {
|
||||
_toggleFab();
|
||||
showCreateChannelFlow(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildFabMenuItem(
|
||||
Symbols.person_add,
|
||||
|
||||
@@ -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<void> showCreateChannelFlow(BuildContext context) async {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
await showModalBottomSheet<void>(
|
||||
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<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 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user