diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index a161d30..a842795 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -59,6 +59,115 @@ class DateSeparatorItem extends ChatItem { DateSeparatorItem(this.date); } +class _EmptyChatWidget extends StatelessWidget { + final Map? 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 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 { final int chatId; final Contact contact; @@ -97,6 +206,7 @@ class _ChatScreenState extends State { final Set _animatedMessageIds = {}; bool _isLoadingHistory = true; + Map? _emptyChatSticker; final TextEditingController _textController = TextEditingController(); final FocusNode _textFocusNode = FocusNode(); StreamSubscription? _apiSubscription; @@ -872,6 +982,11 @@ class _ChatScreenState extends State { _isLoadingHistory = false; }); _updatePinnedMessage(); + + // Если чат пустой, загружаем стикер для пустого состояния + if (_messages.isEmpty && !widget.isChannel) { + _loadEmptyChatSticker(); + } } try { @@ -957,6 +1072,11 @@ class _ChatScreenState extends State { _isLoadingHistory = false; }); _updatePinnedMessage(); + + // Если чат пустой, загружаем стикер для пустого состояния + if (_messages.isEmpty && !widget.isChannel) { + _loadEmptyChatSticker(); + } } catch (e) { print("❌ Ошибка при загрузке с сервера: $e"); if (mounted) { @@ -1148,6 +1268,66 @@ class _ChatScreenState extends State { } } + Future _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: () => {}, + ) + .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; + // Сохраняем также 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() { Message? latestPinned; for (int i = _messages.length - 1; i >= 0; i--) { @@ -1468,6 +1648,51 @@ class _ChatScreenState extends State { } } + Future _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 _sendMessage() async { final originalText = _textController.text.trim(); if (originalText.isNotEmpty) { @@ -2499,16 +2724,21 @@ class _ChatScreenState extends State { key: ValueKey('loading'), child: CircularProgressIndicator(), ) - : AnimatedPadding( - key: const ValueKey('chat_list'), - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOutCubic, - padding: EdgeInsets.only( - bottom: MediaQuery.of( - context, - ).viewInsets.bottom, - ), - child: ScrollablePositionedList.builder( + : _messages.isEmpty && !widget.isChannel + ? _EmptyChatWidget( + sticker: _emptyChatSticker, + onStickerTap: _sendEmptyChatSticker, + ) + : AnimatedPadding( + key: const ValueKey('chat_list'), + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOutCubic, + padding: EdgeInsets.only( + bottom: MediaQuery.of( + context, + ).viewInsets.bottom, + ), + child: ScrollablePositionedList.builder( itemScrollController: _itemScrollController, itemPositionsListener: _itemPositionsListener, reverse: true, diff --git a/lib/screens/settings/storage_screen.dart b/lib/screens/settings/storage_screen.dart index edd8742..fa9c8f7 100644 --- a/lib/screens/settings/storage_screen.dart +++ b/lib/screens/settings/storage_screen.dart @@ -3,6 +3,8 @@ import 'package:path_provider/path_provider.dart'; import 'dart:io'; import 'dart:math'; 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 { final bool isModal; @@ -35,6 +37,9 @@ class _StorageScreenState extends State _buildStorageDetails(colors), const SizedBox(height: 20), + _buildDownloadFolderSetting(colors), + const SizedBox(height: 20), + _buildActionButtons(colors), ], ), @@ -294,6 +299,10 @@ class _StorageScreenState extends State const SizedBox(height: 32), + _buildDownloadFolderSetting(colors), + + const SizedBox(height: 32), + _buildActionButtons(colors), ], ), @@ -380,6 +389,9 @@ class _StorageScreenState extends State _buildStorageDetails(colors), const SizedBox(height: 20), + _buildDownloadFolderSetting(colors), + const SizedBox(height: 20), + _buildActionButtons(colors), ], ), @@ -654,6 +666,202 @@ class _StorageScreenState extends State ); } + Future _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 _resetDownloadFolder() async { + final confirmed = await showDialog( + 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( + 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) { return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/utils/download_path_helper.dart b/lib/utils/download_path_helper.dart new file mode 100644 index 0000000..cc3ed19 --- /dev/null +++ b/lib/utils/download_path_helper.dart @@ -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 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 _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 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 getSavedDownloadPath() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_downloadPathKey); + } + + /// Получить путь для отображения (сохраненный или дефолтный) + static Future getDisplayPath() async { + final savedPath = await getSavedDownloadPath(); + if (savedPath != null && savedPath.isNotEmpty) { + return savedPath; + } + + final defaultDir = await _getDefaultDownloadDirectory(); + return defaultDir?.path ?? 'Не указано'; + } +} + diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index 9db1415..a4c7d5d 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -27,6 +27,7 @@ import 'package:gwid/services/cache_service.dart'; import 'package:video_player/video_player.dart'; import 'package:gwid/services/music_player_service.dart'; import 'package:platform_info/platform_info.dart'; +import 'package:gwid/utils/download_path_helper.dart'; bool _currentIsDark = false; @@ -3297,24 +3298,8 @@ class ChatMessageBubble extends StatelessWidget { FileDownloadProgressService().updateProgress(fileId, 0.0); try { - // Get Downloads directory - io.Directory? downloadDir; - - 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(); - } + // Get Downloads directory using helper + final downloadDir = await DownloadPathHelper.getDownloadDirectory(); if (downloadDir == null || !await downloadDir.exists()) { throw Exception('Downloads directory not found'); @@ -5477,33 +5462,8 @@ class _FullScreenPhotoViewerState extends State { if (widget.attach == null) return; try { - // Get Downloads directory - io.Directory? downloadDir; - - 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(); - } + // Get Downloads directory using helper + final downloadDir = await DownloadPathHelper.getDownloadDirectory(); if (downloadDir == null || !await downloadDir.exists()) { throw Exception('Downloads directory not found');