стикер в пустых чатах как в тг

This commit is contained in:
jganenok
2025-12-04 09:22:16 +07:00
parent 8210e6c376
commit bcc7e499de
4 changed files with 545 additions and 55 deletions

View File

@@ -59,6 +59,115 @@ class DateSeparatorItem extends ChatItem {
DateSeparatorItem(this.date); DateSeparatorItem(this.date);
} }
class _EmptyChatWidget extends StatelessWidget {
final Map<String, dynamic>? sticker;
final VoidCallback? onStickerTap;
const _EmptyChatWidget({this.sticker, this.onStickerTap});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
print('🎨 _EmptyChatWidget.build: sticker=${sticker != null ? "есть" : "null"}');
if (sticker != null) {
print('🎨 Стикер данные: ${sticker}');
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (sticker != null) ...[
// Показываем стикер (LOTTIE или обычное изображение) с возможностью нажатия
GestureDetector(
onTap: onStickerTap,
child: _buildSticker(sticker!),
),
const SizedBox(height: 24),
] else ...[
// Показываем индикатор загрузки, пока стикер не загружен
const SizedBox(
width: 170,
height: 170,
child: Center(
child: CircularProgressIndicator(),
),
),
const SizedBox(height: 24),
],
Text(
'Сообщений пока нет, напишите первым или отправьте этот стикер',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: colors.onSurface.withOpacity(0.6),
),
),
],
),
);
}
Widget _buildSticker(Map<String, dynamic> sticker) {
final url = sticker['url'] as String?;
final lottieUrl = sticker['lottieUrl'] as String?;
final width = (sticker['width'] as num?)?.toDouble() ?? 170.0;
final height = (sticker['height'] as num?)?.toDouble() ?? 170.0;
print('🎨 _buildSticker: url=$url, lottieUrl=$lottieUrl, width=$width, height=$height');
// Для отображения используем обычный url (статичное изображение)
// lottieUrl - это для анимации, но пока используем статичное изображение
// Если есть url, используем его, иначе пробуем lottieUrl
final imageUrl = url ?? lottieUrl;
print('🎨 Используемый URL для стикера: $imageUrl');
if (imageUrl != null && imageUrl.isNotEmpty) {
return SizedBox(
width: width,
height: height,
child: Image.network(
imageUrl,
fit: BoxFit.contain,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) {
print('✅ Стикер успешно загружен');
return child;
}
print('⏳ Загрузка стикера: ${loadingProgress.cumulativeBytesLoaded}/${loadingProgress.expectedTotalBytes}');
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
);
},
errorBuilder: (context, error, stackTrace) {
print('❌ Ошибка загрузки стикера: $error');
print('❌ StackTrace: $stackTrace');
return Icon(
Icons.emoji_emotions,
size: width,
color: Colors.grey,
);
},
),
);
}
print('❌ URL стикера пустой или null');
return Icon(
Icons.emoji_emotions,
size: width,
color: Colors.grey,
);
}
}
class ChatScreen extends StatefulWidget { class ChatScreen extends StatefulWidget {
final int chatId; final int chatId;
final Contact contact; final Contact contact;
@@ -97,6 +206,7 @@ class _ChatScreenState extends State<ChatScreen> {
final Set<String> _animatedMessageIds = {}; final Set<String> _animatedMessageIds = {};
bool _isLoadingHistory = true; bool _isLoadingHistory = true;
Map<String, dynamic>? _emptyChatSticker;
final TextEditingController _textController = TextEditingController(); final TextEditingController _textController = TextEditingController();
final FocusNode _textFocusNode = FocusNode(); final FocusNode _textFocusNode = FocusNode();
StreamSubscription? _apiSubscription; StreamSubscription? _apiSubscription;
@@ -872,6 +982,11 @@ class _ChatScreenState extends State<ChatScreen> {
_isLoadingHistory = false; _isLoadingHistory = false;
}); });
_updatePinnedMessage(); _updatePinnedMessage();
// Если чат пустой, загружаем стикер для пустого состояния
if (_messages.isEmpty && !widget.isChannel) {
_loadEmptyChatSticker();
}
} }
try { try {
@@ -957,6 +1072,11 @@ class _ChatScreenState extends State<ChatScreen> {
_isLoadingHistory = false; _isLoadingHistory = false;
}); });
_updatePinnedMessage(); _updatePinnedMessage();
// Если чат пустой, загружаем стикер для пустого состояния
if (_messages.isEmpty && !widget.isChannel) {
_loadEmptyChatSticker();
}
} catch (e) { } catch (e) {
print("❌ Ошибка при загрузке с сервера: $e"); print("❌ Ошибка при загрузке с сервера: $e");
if (mounted) { if (mounted) {
@@ -1148,6 +1268,66 @@ class _ChatScreenState extends State<ChatScreen> {
} }
} }
Future<void> _loadEmptyChatSticker() async {
try {
// Список доступных ID стикеров для пустого чата
final availableStickerIds = [272821, 295349, 13571];
// Выбираем случайный ID
final random = DateTime.now().millisecondsSinceEpoch % availableStickerIds.length;
final selectedStickerId = availableStickerIds[random];
print('🎨 Загружаем стикер для пустого чата (ID: $selectedStickerId)...');
final seq = ApiService.instance.sendRawRequest(28, {
"type": "STICKER",
"ids": [selectedStickerId],
});
if (seq == -1) {
print('Не удалось отправить запрос на получение стикера');
return;
}
final response = await ApiService.instance.messages
.firstWhere(
(msg) => msg['seq'] == seq && msg['opcode'] == 28,
orElse: () => <String, dynamic>{},
)
.timeout(
const Duration(seconds: 10),
onTimeout: () => throw TimeoutException(
'Превышено время ожидания ответа от сервера',
),
);
if (response.isEmpty || response['payload'] == null) {
print('Не получен ответ от сервера для стикера');
return;
}
final stickers = response['payload']['stickers'] as List?;
print('🎨 Получен ответ со стикерами: ${stickers?.length ?? 0}');
if (stickers != null && stickers.isNotEmpty) {
final sticker = stickers.first as Map<String, dynamic>;
// Сохраняем также stickerId для отправки
final stickerId = sticker['id'] as int?;
print('🎨 Данные стикера: id=$stickerId, url=${sticker['url']}, lottieUrl=${sticker['lottieUrl']}, width=${sticker['width']}, height=${sticker['height']}');
if (mounted) {
setState(() {
_emptyChatSticker = {
...sticker,
'stickerId': stickerId, // Сохраняем ID для отправки
};
});
print('✅ Стикер для пустого чата загружен и сохранен (ID: $stickerId)');
}
} else {
print('❌ Стикеры не найдены в ответе');
}
} catch (e) {
print('❌ Ошибка при загрузке стикера для пустого чата: $e');
}
}
void _updatePinnedMessage() { void _updatePinnedMessage() {
Message? latestPinned; Message? latestPinned;
for (int i = _messages.length - 1; i >= 0; i--) { for (int i = _messages.length - 1; i >= 0; i--) {
@@ -1468,6 +1648,51 @@ class _ChatScreenState extends State<ChatScreen> {
} }
} }
Future<void> _sendEmptyChatSticker() async {
if (_emptyChatSticker == null) {
print('❌ Стикер не загружен, невозможно отправить');
return;
}
final stickerId = _emptyChatSticker!['stickerId'] as int?;
if (stickerId == null) {
print('❌ ID стикера не найден');
return;
}
try {
print('🎨 Отправляем стикер (ID: $stickerId) в чат ${widget.chatId}');
final cid = DateTime.now().millisecondsSinceEpoch;
final payload = {
"chatId": widget.chatId,
"message": {
"cid": cid,
"attaches": [
{
"_type": "STICKER",
"stickerId": stickerId,
}
],
},
"notify": true,
};
ApiService.instance.sendRawRequest(64, payload);
print('✅ Стикер отправлен (opcode 64, cid: $cid)');
} catch (e) {
print('❌ Ошибка при отправке стикера: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка при отправке стикера: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _sendMessage() async { Future<void> _sendMessage() async {
final originalText = _textController.text.trim(); final originalText = _textController.text.trim();
if (originalText.isNotEmpty) { if (originalText.isNotEmpty) {
@@ -2499,16 +2724,21 @@ class _ChatScreenState extends State<ChatScreen> {
key: ValueKey('loading'), key: ValueKey('loading'),
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
) )
: AnimatedPadding( : _messages.isEmpty && !widget.isChannel
key: const ValueKey('chat_list'), ? _EmptyChatWidget(
duration: const Duration(milliseconds: 300), sticker: _emptyChatSticker,
curve: Curves.easeInOutCubic, onStickerTap: _sendEmptyChatSticker,
padding: EdgeInsets.only( )
bottom: MediaQuery.of( : AnimatedPadding(
context, key: const ValueKey('chat_list'),
).viewInsets.bottom, duration: const Duration(milliseconds: 300),
), curve: Curves.easeInOutCubic,
child: ScrollablePositionedList.builder( padding: EdgeInsets.only(
bottom: MediaQuery.of(
context,
).viewInsets.bottom,
),
child: ScrollablePositionedList.builder(
itemScrollController: _itemScrollController, itemScrollController: _itemScrollController,
itemPositionsListener: _itemPositionsListener, itemPositionsListener: _itemPositionsListener,
reverse: true, reverse: true,

View File

@@ -3,6 +3,8 @@ import 'package:path_provider/path_provider.dart';
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:gwid/api/api_service.dart'; import 'package:gwid/api/api_service.dart';
import 'package:file_picker/file_picker.dart';
import 'package:gwid/utils/download_path_helper.dart';
class StorageScreen extends StatefulWidget { class StorageScreen extends StatefulWidget {
final bool isModal; final bool isModal;
@@ -35,6 +37,9 @@ class _StorageScreenState extends State<StorageScreen>
_buildStorageDetails(colors), _buildStorageDetails(colors),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildDownloadFolderSetting(colors),
const SizedBox(height: 20),
_buildActionButtons(colors), _buildActionButtons(colors),
], ],
), ),
@@ -294,6 +299,10 @@ class _StorageScreenState extends State<StorageScreen>
const SizedBox(height: 32), const SizedBox(height: 32),
_buildDownloadFolderSetting(colors),
const SizedBox(height: 32),
_buildActionButtons(colors), _buildActionButtons(colors),
], ],
), ),
@@ -380,6 +389,9 @@ class _StorageScreenState extends State<StorageScreen>
_buildStorageDetails(colors), _buildStorageDetails(colors),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildDownloadFolderSetting(colors),
const SizedBox(height: 20),
_buildActionButtons(colors), _buildActionButtons(colors),
], ],
), ),
@@ -654,6 +666,202 @@ class _StorageScreenState extends State<StorageScreen>
); );
} }
Future<void> _selectDownloadFolder() async {
try {
String? selectedDirectory;
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
// На десктопе используем getDirectoryPath
selectedDirectory = await FilePicker.platform.getDirectoryPath();
} else {
// На мобильных платформах file_picker может не поддерживать выбор папки
// Используем диалог с текстовым вводом или просто показываем сообщение
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Выбор папки доступен только на десктопных платформах'),
duration: Duration(seconds: 3),
),
);
}
return;
}
if (selectedDirectory != null && selectedDirectory.isNotEmpty) {
await DownloadPathHelper.setDownloadDirectory(selectedDirectory);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Папка загрузки установлена: $selectedDirectory'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 3),
),
);
setState(() {}); // Обновляем UI
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка при выборе папки: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _resetDownloadFolder() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Сбросить папку загрузки'),
content: const Text(
'Вернуть папку загрузки к значению по умолчанию?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Отмена'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Сбросить'),
),
],
),
);
if (confirmed == true) {
await DownloadPathHelper.setDownloadDirectory(null);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Папка загрузки сброшена к значению по умолчанию'),
backgroundColor: Colors.green,
),
);
setState(() {}); // Обновляем UI
}
}
}
Widget _buildDownloadFolderSetting(ColorScheme colors) {
return FutureBuilder<String>(
future: DownloadPathHelper.getDisplayPath(),
builder: (context, snapshot) {
final currentPath = snapshot.data ?? 'Загрузка...';
final isCustom = snapshot.hasData &&
currentPath != 'Не указано' &&
!currentPath.contains('Downloads') &&
!currentPath.contains('Download');
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colors.outline.withOpacity(0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.folder_outlined,
color: colors.primary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Папка загрузки файлов',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: colors.onSurface,
),
),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest.withOpacity(0.5),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Текущая папка:',
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withOpacity(0.6),
),
),
const SizedBox(height: 4),
Text(
currentPath,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colors.onSurface,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (isCustom)
Icon(
Icons.check_circle,
color: colors.primary,
size: 20,
),
],
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _selectDownloadFolder,
icon: const Icon(Icons.folder_open),
label: const Text('Выбрать папку'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
if (isCustom) ...[
const SizedBox(width: 12),
OutlinedButton(
onPressed: _resetDownloadFolder,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
),
child: const Icon(Icons.refresh),
),
],
],
),
],
),
);
},
);
}
Widget _buildActionButtons(ColorScheme colors) { Widget _buildActionButtons(ColorScheme colors) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -0,0 +1,92 @@
import 'dart:io' as io;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class DownloadPathHelper {
static const String _downloadPathKey = 'custom_download_path';
/// Получить папку для загрузки файлов
/// Сначала проверяет сохраненный путь, если его нет - использует дефолтный
static Future<io.Directory?> getDownloadDirectory() async {
final prefs = await SharedPreferences.getInstance();
final customPath = prefs.getString(_downloadPathKey);
if (customPath != null && customPath.isNotEmpty) {
final dir = io.Directory(customPath);
if (await dir.exists()) {
return dir;
}
}
// Используем дефолтную папку
return _getDefaultDownloadDirectory();
}
/// Получить дефолтную папку для загрузки
static Future<io.Directory?> _getDefaultDownloadDirectory() async {
if (io.Platform.isAndroid) {
final directory = await getExternalStorageDirectory();
if (directory != null) {
// Пробуем найти папку Download/Downloads
var downloadDir = io.Directory(
'${directory.path.split('Android')[0]}Download',
);
if (!await downloadDir.exists()) {
downloadDir = io.Directory(
'${directory.path.split('Android')[0]}Downloads',
);
}
if (await downloadDir.exists()) {
return downloadDir;
}
return directory;
}
} else if (io.Platform.isIOS) {
final directory = await getApplicationDocumentsDirectory();
return directory;
} else if (io.Platform.isWindows || io.Platform.isLinux) {
final homeDir =
io.Platform.environment['HOME'] ??
io.Platform.environment['USERPROFILE'] ??
'';
if (homeDir.isNotEmpty) {
final downloadDir = io.Directory('$homeDir/Downloads');
if (await downloadDir.exists()) {
return downloadDir;
}
return io.Directory(homeDir);
}
}
// Fallback
return await getApplicationDocumentsDirectory();
}
/// Сохранить выбранную пользователем папку
static Future<void> setDownloadDirectory(String? path) async {
final prefs = await SharedPreferences.getInstance();
if (path != null && path.isNotEmpty) {
await prefs.setString(_downloadPathKey, path);
} else {
await prefs.remove(_downloadPathKey);
}
}
/// Получить текущий сохраненный путь (может быть null)
static Future<String?> getSavedDownloadPath() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_downloadPathKey);
}
/// Получить путь для отображения (сохраненный или дефолтный)
static Future<String> getDisplayPath() async {
final savedPath = await getSavedDownloadPath();
if (savedPath != null && savedPath.isNotEmpty) {
return savedPath;
}
final defaultDir = await _getDefaultDownloadDirectory();
return defaultDir?.path ?? 'Не указано';
}
}

View File

@@ -27,6 +27,7 @@ import 'package:gwid/services/cache_service.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import 'package:gwid/services/music_player_service.dart'; import 'package:gwid/services/music_player_service.dart';
import 'package:platform_info/platform_info.dart'; import 'package:platform_info/platform_info.dart';
import 'package:gwid/utils/download_path_helper.dart';
bool _currentIsDark = false; bool _currentIsDark = false;
@@ -3297,24 +3298,8 @@ class ChatMessageBubble extends StatelessWidget {
FileDownloadProgressService().updateProgress(fileId, 0.0); FileDownloadProgressService().updateProgress(fileId, 0.0);
try { try {
// Get Downloads directory // Get Downloads directory using helper
io.Directory? downloadDir; final downloadDir = await DownloadPathHelper.getDownloadDirectory();
if (io.Platform.isAndroid) {
downloadDir = await getExternalStorageDirectory();
} else if (io.Platform.isIOS) {
final directory = await getApplicationDocumentsDirectory();
downloadDir = directory;
} else if (io.Platform.isWindows || io.Platform.isLinux) {
// For desktop platforms, use Downloads directory
final homeDir =
io.Platform.environment['HOME'] ??
io.Platform.environment['USERPROFILE'] ??
'';
downloadDir = io.Directory('$homeDir/Downloads');
} else {
downloadDir = await getApplicationDocumentsDirectory();
}
if (downloadDir == null || !await downloadDir.exists()) { if (downloadDir == null || !await downloadDir.exists()) {
throw Exception('Downloads directory not found'); throw Exception('Downloads directory not found');
@@ -5477,33 +5462,8 @@ class _FullScreenPhotoViewerState extends State<FullScreenPhotoViewer> {
if (widget.attach == null) return; if (widget.attach == null) return;
try { try {
// Get Downloads directory // Get Downloads directory using helper
io.Directory? downloadDir; final downloadDir = await DownloadPathHelper.getDownloadDirectory();
if (io.Platform.isAndroid) {
final directory = await getExternalStorageDirectory();
if (directory != null) {
downloadDir = io.Directory(
'${directory.path.split('Android')[0]}Download',
);
if (!await downloadDir.exists()) {
downloadDir = io.Directory(
'${directory.path.split('Android')[0]}Downloads',
);
}
}
} else if (io.Platform.isIOS) {
final directory = await getApplicationDocumentsDirectory();
downloadDir = directory;
} else if (io.Platform.isWindows || io.Platform.isLinux) {
final homeDir =
io.Platform.environment['HOME'] ??
io.Platform.environment['USERPROFILE'] ??
'';
downloadDir = io.Directory('$homeDir/Downloads');
} else {
downloadDir = await getApplicationDocumentsDirectory();
}
if (downloadDir == null || !await downloadDir.exists()) { if (downloadDir == null || !await downloadDir.exists()) {
throw Exception('Downloads directory not found'); throw Exception('Downloads directory not found');