diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 65b6126..ede3788 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:ui'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; @@ -2972,499 +2973,530 @@ class _ChatScreenState extends State { Widget build(BuildContext context) { final theme = context.watch(); - return Scaffold( - extendBodyBehindAppBar: theme.useGlassPanels, - resizeToAvoidBottomInset: false, - appBar: _buildAppBar(), - body: Stack( - children: [ - Positioned.fill(child: _buildChatWallpaper(theme)), - Column( - children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 250), - switchInCurve: Curves.easeInOutCubic, - switchOutCurve: Curves.easeInOutCubic, - transitionBuilder: (child, animation) { - return SlideTransition( - position: Tween( - begin: const Offset(0, -0.5), - end: Offset.zero, - ).animate(animation), - child: FadeTransition(opacity: animation, child: child), - ); - }, - child: _pinnedMessage != null - ? SafeArea( - key: ValueKey(_pinnedMessage!.id), - child: InkWell( + final isDesktop = + kIsWeb || + defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.linux || + defaultTargetPlatform == TargetPlatform.macOS; + + final body = Stack( + children: [ + Positioned.fill(child: _buildChatWallpaper(theme)), + Column( + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: (child, animation) { + return SlideTransition( + position: Tween( + begin: const Offset(0, -0.5), + end: Offset.zero, + ).animate(animation), + child: FadeTransition(opacity: animation, child: child), + ); + }, + child: _pinnedMessage != null + ? SafeArea( + key: ValueKey(_pinnedMessage!.id), + child: InkWell( + onTap: _scrollToPinnedMessage, + child: PinnedMessageWidget( + pinnedMessage: _pinnedMessage!, + contacts: _contactDetailsCache, + myId: _actualMyId ?? 0, onTap: _scrollToPinnedMessage, - child: PinnedMessageWidget( - pinnedMessage: _pinnedMessage!, - contacts: _contactDetailsCache, - myId: _actualMyId ?? 0, - onTap: _scrollToPinnedMessage, - onClose: () { - setState(() { - _pinnedMessage = null; - }); - }, - ), + onClose: () { + setState(() { + _pinnedMessage = null; + }); + }, ), - ) - : const SizedBox.shrink(key: ValueKey('empty')), - ), - Expanded( - child: Stack( - children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - switchInCurve: Curves.easeInOutCubic, - switchOutCurve: Curves.easeInOutCubic, - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: ScaleTransition( - scale: Tween(begin: 0.8, end: 1.0).animate( - CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - ), + ), + ) + : const SizedBox.shrink(key: ValueKey('empty')), + ), + Expanded( + child: Stack( + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: Tween(begin: 0.8, end: 1.0).animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, ), - child: child, ), - ); - }, - child: (!_isIdReady || _isLoadingHistory) - ? const Center( - key: ValueKey('loading'), - child: CircularProgressIndicator(), - ) - : _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( + child: child, + ), + ); + }, + child: (!_isIdReady || _isLoadingHistory) + ? const Center( + key: ValueKey('loading'), + child: CircularProgressIndicator(), + ) + : _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: () { + final baseInset = MediaQuery.of( context, - ).viewInsets.bottom, + ).viewInsets.bottom; + final isAndroid = + defaultTargetPlatform == + TargetPlatform.android; + if (!isAndroid) { + return baseInset; + } + final keyboardVisible = baseInset > 0.0; + if (keyboardVisible && + theme + .ignoreMobileBottomPaddingWhenKeyboard) { + return baseInset; + } + return baseInset + + theme.mobileChatBottomPadding; + }(), + ), + child: ScrollablePositionedList.builder( + itemScrollController: _itemScrollController, + itemPositionsListener: _itemPositionsListener, + reverse: true, + padding: EdgeInsets.fromLTRB( + 8.0, + 8.0, + 8.0, + widget.isChannel + ? 24.0 + : (isDesktop ? 100.0 : 0.0), ), - child: ScrollablePositionedList.builder( - itemScrollController: _itemScrollController, - itemPositionsListener: _itemPositionsListener, - reverse: true, - padding: EdgeInsets.fromLTRB( - 8.0, - 8.0, - 8.0, - widget.isChannel ? 16.0 : 100.0, - ), - itemCount: _chatItems.length, - itemBuilder: (context, index) { - final mappedIndex = - _chatItems.length - 1 - index; - final item = _chatItems[mappedIndex]; - final isLastVisual = - index == _chatItems.length - 1; + itemCount: _chatItems.length, + itemBuilder: (context, index) { + final mappedIndex = + _chatItems.length - 1 - index; + final item = _chatItems[mappedIndex]; + final isLastVisual = + index == _chatItems.length - 1; - // Убрали вызов _loadMore() отсюда - он вызывается из _itemPositionsListener - // чтобы избежать setState() во время build фазы + // Убрали вызов _loadMore() отсюда - он вызывается из _itemPositionsListener + // чтобы избежать setState() во время build фазы - if (item is MessageItem) { - final message = item.message; - final key = _messageKeys.putIfAbsent( - message.id, - () => GlobalKey(), + if (item is MessageItem) { + final message = item.message; + final key = _messageKeys.putIfAbsent( + message.id, + () => GlobalKey(), + ); + final bool isHighlighted = + _isSearching && + _searchResults.isNotEmpty && + _currentResultIndex != -1 && + message.id == + _searchResults[_currentResultIndex] + .id; + + final isControlMessage = message.attaches.any( + (a) => a['_type'] == 'CONTROL', + ); + if (isControlMessage) { + return _ControlMessageChip( + message: message, + contacts: _contactDetailsCache, + myId: _actualMyId ?? widget.myId, ); - final bool isHighlighted = - _isSearching && - _searchResults.isNotEmpty && - _currentResultIndex != -1 && - message.id == - _searchResults[_currentResultIndex] - .id; - - final isControlMessage = message.attaches - .any((a) => a['_type'] == 'CONTROL'); - if (isControlMessage) { - return _ControlMessageChip( - message: message, - contacts: _contactDetailsCache, - myId: _actualMyId ?? widget.myId, - ); - } - - final bool isMe = - item.message.senderId == _actualMyId; - - MessageReadStatus? readStatus; - if (isMe) { - final messageId = item.message.id; - if (messageId.startsWith('local_')) { - readStatus = MessageReadStatus.sending; - } else { - readStatus = MessageReadStatus.sent; - } - } - - String? forwardedFrom; - String? forwardedFromAvatarUrl; - if (message.isForwarded) { - final link = message.link; - if (link is Map) { - final chatName = - link['chatName'] as String?; - final chatIconUrl = - link['chatIconUrl'] as String?; - - if (chatName != null) { - forwardedFrom = chatName; - forwardedFromAvatarUrl = chatIconUrl; - } else { - final forwardedMessage = - link['message'] - as Map?; - final originalSenderId = - forwardedMessage?['sender'] - as int?; - if (originalSenderId != null) { - final originalSenderContact = - _contactDetailsCache[originalSenderId]; - if (originalSenderContact == null) { - _loadContactIfNeeded( - originalSenderId, - ); - forwardedFrom = - 'Участник $originalSenderId'; - forwardedFromAvatarUrl = null; - } else { - forwardedFrom = - originalSenderContact.name; - forwardedFromAvatarUrl = - originalSenderContact - .photoBaseUrl; - } - } - } - } - } - String? senderName; - if (widget.isGroupChat && !isMe) { - bool shouldShowName = true; - if (mappedIndex > 0) { - final previousItem = - _chatItems[mappedIndex - 1]; - if (previousItem is MessageItem) { - final previousMessage = - previousItem.message; - if (previousMessage.senderId == - message.senderId) { - final timeDifferenceInMinutes = - (message.time - - previousMessage.time) / - (1000 * 60); - if (timeDifferenceInMinutes < 5) { - shouldShowName = false; - } - } - } - } - if (shouldShowName) { - final senderContact = - _contactDetailsCache[message - .senderId]; - if (senderContact != null) { - senderName = getContactDisplayName( - contactId: senderContact.id, - originalName: senderContact.name, - originalFirstName: - senderContact.firstName, - originalLastName: - senderContact.lastName, - ); - } else { - senderName = 'ID ${message.senderId}'; - _loadContactIfNeeded( - message.senderId, - ); - } - } - } - final hasPhoto = item.message.attaches.any( - (a) => a['_type'] == 'PHOTO', - ); - final isNew = !_animatedMessageIds.contains( - item.message.id, - ); - final deferImageLoading = - hasPhoto && - isNew && - !_anyOptimize && - !context - .read() - .animatePhotoMessages; - - String? decryptedText; - if (_isEncryptionPasswordSetForCurrentChat && - _encryptionConfigForCurrentChat != - null && - _encryptionConfigForCurrentChat! - .password - .isNotEmpty && - item.message.text.startsWith( - ChatEncryptionService.encryptedPrefix, - )) { - decryptedText = - ChatEncryptionService.decryptWithPassword( - _encryptionConfigForCurrentChat! - .password, - item.message.text, - ); - } - - final bubble = ChatMessageBubble( - key: key, - message: item.message, - isMe: isMe, - readStatus: readStatus, - isReactionSending: _sendingReactions - .contains(item.message.id), - deferImageLoading: deferImageLoading, - myUserId: _actualMyId, - chatId: widget.chatId, - isEncryptionPasswordSet: - _isEncryptionPasswordSetForCurrentChat, - decryptedText: decryptedText, - onReply: widget.isChannel - ? null - : () => _replyToMessage(item.message), - onForward: () => - _forwardMessage(item.message), - onEdit: isMe - ? () => _editMessage(item.message) - : null, - canEditMessage: isMe - ? item.message.canEdit(_actualMyId!) - : null, - onDeleteForMe: isMe - ? () async { - await ApiService.instance - .deleteMessage( - widget.chatId, - item.message.id, - forMe: true, - ); - widget.onChatUpdated?.call(); - } - : null, - onDeleteForAll: isMe - ? () async { - await ApiService.instance - .deleteMessage( - widget.chatId, - item.message.id, - forMe: false, - ); - widget.onChatUpdated?.call(); - } - : null, - onReaction: (emoji) { - _updateReactionOptimistically( - item.message.id, - emoji, - ); - ApiService.instance.sendReaction( - widget.chatId, - item.message.id, - emoji, - ); - widget.onChatUpdated?.call(); - }, - onRemoveReaction: () { - _removeReactionOptimistically( - item.message.id, - ); - ApiService.instance.removeReaction( - widget.chatId, - item.message.id, - ); - widget.onChatUpdated?.call(); - }, - isGroupChat: widget.isGroupChat, - isChannel: widget.isChannel, - senderName: senderName, - forwardedFrom: forwardedFrom, - forwardedFromAvatarUrl: - forwardedFromAvatarUrl, - contactDetailsCache: _contactDetailsCache, - onReplyTap: _scrollToMessage, - useAutoReplyColor: context - .read() - .useAutoReplyColor, - customReplyColor: context - .read() - .customReplyColor, - isFirstInGroup: item.isFirstInGroup, - isLastInGroup: item.isLastInGroup, - isGrouped: item.isGrouped, - avatarVerticalOffset: - -8.0, // Смещение аватарки вверх на 8px - onComplain: () => - _showComplaintDialog(item.message.id), - ); - - Widget finalMessageWidget = - bubble as Widget; - - if (isHighlighted) { - return TweenAnimationBuilder( - duration: const Duration( - milliseconds: 600, - ), - tween: Tween( - begin: 0.3, - end: 0.6, - ), - curve: Curves.easeInOut, - builder: (context, value, child) { - return Container( - margin: const EdgeInsets.symmetric( - vertical: 2, - ), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .primaryContainer - .withOpacity(value), - borderRadius: - BorderRadius.circular(16), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.primary, - width: 1.5, - ), - ), - child: child, - ); - }, - child: finalMessageWidget, - ); - } - - // Плавное появление новых сообщений - if (isNew && !_anyOptimize) { - return TweenAnimationBuilder( - duration: const Duration( - milliseconds: 400, - ), - tween: Tween( - begin: 0.0, - end: 1.0, - ), - curve: Curves.easeOutCubic, - builder: (context, value, child) { - return Opacity( - opacity: value, - child: Transform.translate( - offset: Offset( - 0, - 20 * (1 - value), - ), - child: child, - ), - ); - }, - child: finalMessageWidget, - ); - } - - return finalMessageWidget; - } else if (item is DateSeparatorItem) { - return _DateSeparatorChip(date: item.date); } - if (isLastVisual && _isLoadingMore) { + + final bool isMe = + item.message.senderId == _actualMyId; + + MessageReadStatus? readStatus; + if (isMe) { + final messageId = item.message.id; + if (messageId.startsWith('local_')) { + readStatus = MessageReadStatus.sending; + } else { + readStatus = MessageReadStatus.sent; + } + } + + String? forwardedFrom; + String? forwardedFromAvatarUrl; + if (message.isForwarded) { + final link = message.link; + if (link is Map) { + final chatName = + link['chatName'] as String?; + final chatIconUrl = + link['chatIconUrl'] as String?; + + if (chatName != null) { + forwardedFrom = chatName; + forwardedFromAvatarUrl = chatIconUrl; + } else { + final forwardedMessage = + link['message'] + as Map?; + final originalSenderId = + forwardedMessage?['sender'] as int?; + if (originalSenderId != null) { + final originalSenderContact = + _contactDetailsCache[originalSenderId]; + if (originalSenderContact == null) { + _loadContactIfNeeded( + originalSenderId, + ); + forwardedFrom = + 'Участник $originalSenderId'; + forwardedFromAvatarUrl = null; + } else { + forwardedFrom = + originalSenderContact.name; + forwardedFromAvatarUrl = + originalSenderContact + .photoBaseUrl; + } + } + } + } + } + String? senderName; + if (widget.isGroupChat && !isMe) { + bool shouldShowName = true; + if (mappedIndex > 0) { + final previousItem = + _chatItems[mappedIndex - 1]; + if (previousItem is MessageItem) { + final previousMessage = + previousItem.message; + if (previousMessage.senderId == + message.senderId) { + final timeDifferenceInMinutes = + (message.time - + previousMessage.time) / + (1000 * 60); + if (timeDifferenceInMinutes < 5) { + shouldShowName = false; + } + } + } + } + if (shouldShowName) { + final senderContact = + _contactDetailsCache[message + .senderId]; + if (senderContact != null) { + senderName = getContactDisplayName( + contactId: senderContact.id, + originalName: senderContact.name, + originalFirstName: + senderContact.firstName, + originalLastName: + senderContact.lastName, + ); + } else { + senderName = 'ID ${message.senderId}'; + _loadContactIfNeeded(message.senderId); + } + } + } + final hasPhoto = item.message.attaches.any( + (a) => a['_type'] == 'PHOTO', + ); + final isNew = !_animatedMessageIds.contains( + item.message.id, + ); + final deferImageLoading = + hasPhoto && + isNew && + !_anyOptimize && + !context + .read() + .animatePhotoMessages; + + String? decryptedText; + if (_isEncryptionPasswordSetForCurrentChat && + _encryptionConfigForCurrentChat != null && + _encryptionConfigForCurrentChat! + .password + .isNotEmpty && + item.message.text.startsWith( + ChatEncryptionService.encryptedPrefix, + )) { + decryptedText = + ChatEncryptionService.decryptWithPassword( + _encryptionConfigForCurrentChat! + .password, + item.message.text, + ); + } + + final bubble = ChatMessageBubble( + key: key, + message: item.message, + isMe: isMe, + readStatus: readStatus, + isReactionSending: _sendingReactions + .contains(item.message.id), + deferImageLoading: deferImageLoading, + myUserId: _actualMyId, + chatId: widget.chatId, + isEncryptionPasswordSet: + _isEncryptionPasswordSetForCurrentChat, + decryptedText: decryptedText, + onReply: widget.isChannel + ? null + : () => _replyToMessage(item.message), + onForward: () => + _forwardMessage(item.message), + onEdit: isMe + ? () => _editMessage(item.message) + : null, + canEditMessage: isMe + ? item.message.canEdit(_actualMyId!) + : null, + onDeleteForMe: isMe + ? () async { + await ApiService.instance + .deleteMessage( + widget.chatId, + item.message.id, + forMe: true, + ); + widget.onChatUpdated?.call(); + } + : null, + onDeleteForAll: isMe + ? () async { + await ApiService.instance + .deleteMessage( + widget.chatId, + item.message.id, + forMe: false, + ); + widget.onChatUpdated?.call(); + } + : null, + onReaction: (emoji) { + _updateReactionOptimistically( + item.message.id, + emoji, + ); + ApiService.instance.sendReaction( + widget.chatId, + item.message.id, + emoji, + ); + widget.onChatUpdated?.call(); + }, + onRemoveReaction: () { + _removeReactionOptimistically( + item.message.id, + ); + ApiService.instance.removeReaction( + widget.chatId, + item.message.id, + ); + widget.onChatUpdated?.call(); + }, + isGroupChat: widget.isGroupChat, + isChannel: widget.isChannel, + senderName: senderName, + forwardedFrom: forwardedFrom, + forwardedFromAvatarUrl: + forwardedFromAvatarUrl, + contactDetailsCache: _contactDetailsCache, + onReplyTap: _scrollToMessage, + useAutoReplyColor: context + .read() + .useAutoReplyColor, + customReplyColor: context + .read() + .customReplyColor, + isFirstInGroup: item.isFirstInGroup, + isLastInGroup: item.isLastInGroup, + isGrouped: item.isGrouped, + avatarVerticalOffset: + -8.0, // Смещение аватарки вверх на 8px + onComplain: () => + _showComplaintDialog(item.message.id), + ); + + Widget finalMessageWidget = bubble as Widget; + + if (isHighlighted) { return TweenAnimationBuilder( duration: const Duration( - milliseconds: 300, + milliseconds: 600, + ), + tween: Tween( + begin: 0.3, + end: 0.6, + ), + curve: Curves.easeInOut, + builder: (context, value, child) { + return Container( + margin: const EdgeInsets.symmetric( + vertical: 2, + ), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primaryContainer + .withOpacity(value), + borderRadius: BorderRadius.circular( + 16, + ), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.primary, + width: 1.5, + ), + ), + child: child, + ); + }, + child: finalMessageWidget, + ); + } + + // Плавное появление новых сообщений + if (isNew && !_anyOptimize) { + return TweenAnimationBuilder( + duration: const Duration( + milliseconds: 400, ), tween: Tween( begin: 0.0, end: 1.0, ), - curve: Curves.easeOut, + curve: Curves.easeOutCubic, builder: (context, value, child) { return Opacity( opacity: value, - child: Transform.scale( - scale: 0.7 + (0.3 * value), + child: Transform.translate( + offset: Offset(0, 20 * (1 - value)), child: child, ), ); }, - child: const Padding( - padding: EdgeInsets.symmetric( - vertical: 12, - ), - child: Center( - child: CircularProgressIndicator(), - ), - ), + child: finalMessageWidget, ); } - return const SizedBox.shrink(); - }, - ), + + return finalMessageWidget; + } else if (item is DateSeparatorItem) { + return _DateSeparatorChip(date: item.date); + } + if (isLastVisual && _isLoadingMore) { + return TweenAnimationBuilder( + duration: const Duration(milliseconds: 300), + tween: Tween(begin: 0.0, end: 1.0), + curve: Curves.easeOut, + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.scale( + scale: 0.7 + (0.3 * value), + child: child, + ), + ); + }, + child: const Padding( + padding: EdgeInsets.symmetric( + vertical: 12, + ), + child: Center( + child: CircularProgressIndicator(), + ), + ), + ); + } + return const SizedBox.shrink(); + }, ), - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 100), - curve: Curves.easeOutQuad, - right: 16, - bottom: - MediaQuery.of(context).viewInsets.bottom + - MediaQuery.of(context).padding.bottom + - 100, - child: AnimatedScale( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutBack, - scale: _showScrollToBottomNotifier.value ? 1.0 : 0.0, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: _showScrollToBottomNotifier.value - ? 1.0 - : 0.0, - child: FloatingActionButton( - mini: true, - onPressed: _scrollToBottom, - elevation: 4, - child: const Icon(Icons.arrow_downward_rounded), ), + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 100), + curve: Curves.easeOutQuad, + right: 16, + bottom: + MediaQuery.of(context).viewInsets.bottom + + MediaQuery.of(context).padding.bottom + + 100, + child: AnimatedScale( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutBack, + scale: _showScrollToBottomNotifier.value ? 1.0 : 0.0, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: _showScrollToBottomNotifier.value ? 1.0 : 0.0, + child: FloatingActionButton( + mini: true, + onPressed: _scrollToBottom, + elevation: 4, + child: const Icon(Icons.arrow_downward_rounded), ), ), ), - ], - ), + ), + ], ), - ], - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 100), - curve: Curves.easeOutQuad, - left: 8, - right: 8, - bottom: - MediaQuery.of(context).viewInsets.bottom + - MediaQuery.of(context).padding.bottom + - 12, - child: _buildTextInput(), - ), - ], + ), + ], + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 100), + curve: Curves.easeOutQuad, + left: 8, + right: 8, + bottom: + MediaQuery.of(context).viewInsets.bottom + + MediaQuery.of(context).padding.bottom + + 12, + child: _buildTextInput(), + ), + ], + ); + + if (isDesktop) { + return Scaffold( + extendBodyBehindAppBar: theme.useGlassPanels, + resizeToAvoidBottomInset: false, + appBar: _buildAppBar(), + body: body, + ); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragEnd: (details) { + final velocity = details.primaryVelocity ?? 0; + if (velocity > 400) { + Navigator.of(context).maybePop(); + } + }, + child: Scaffold( + extendBodyBehindAppBar: theme.useGlassPanels, + resizeToAvoidBottomInset: false, + appBar: _buildAppBar(), + body: body, ), ); } diff --git a/lib/screens/chats_screen.dart b/lib/screens/chats_screen.dart index 751af6b..27d3f29 100644 --- a/lib/screens/chats_screen.dart +++ b/lib/screens/chats_screen.dart @@ -1997,13 +1997,63 @@ class _ChatsScreenState extends State ], ); + final themeProvider = context.watch(); + Widget? chatsListBackground; + if (themeProvider.useExperimentalChatsListBackground) { + switch (themeProvider.experimentalChatsListBackgroundType) { + case ChatWallpaperType.solid: + chatsListBackground = Container(color: themeProvider.experimentalChatsListBackgroundColor1); + break; + case ChatWallpaperType.gradient: + chatsListBackground = Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + themeProvider.experimentalChatsListBackgroundColor1, + themeProvider.experimentalChatsListBackgroundColor2, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + ); + break; + case ChatWallpaperType.image: + if (themeProvider.experimentalChatsListBackgroundImagePath != null) { + chatsListBackground = Image.file( + File(themeProvider.experimentalChatsListBackgroundImagePath!), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ); + } + break; + case ChatWallpaperType.video: + break; + } + } + + final bodyContentWithBackground = chatsListBackground != null + ? Stack( + children: [ + Positioned.fill(child: chatsListBackground), + bodyContent, + ], + ) + : bodyContent; + if (widget.hasScaffold) { return Builder( builder: (context) { - return Scaffold( + final platform = Theme.of(context).platform; + final isMobile = + platform == TargetPlatform.android || + platform == TargetPlatform.iOS; + + Widget scaffold = Scaffold( appBar: _buildAppBar(context), drawer: _buildAppDrawer(context), - body: Row(children: [Expanded(child: bodyContent)]), + body: Row(children: [Expanded(child: bodyContentWithBackground)]), floatingActionButton: FloatingActionButton( onPressed: () { _showAddMenu(context); @@ -2013,10 +2063,50 @@ class _ChatsScreenState extends State child: const Icon(Icons.edit), ), ); + + if (!isMobile) return scaffold; + + final scaffoldKey = GlobalKey(); + scaffold = Scaffold( + key: scaffoldKey, + appBar: _buildAppBar(context), + drawer: _buildAppDrawer(context), + body: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragEnd: (details) { + final velocity = details.primaryVelocity ?? 0; + // Делаем жест проще: реагируем на более медленный свайп + if (velocity < -150) { + if (_folderTabController.length > 0 && + _folderTabController.index == + _folderTabController.length - 1) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (ctx) => const SettingsScreen(), + ), + ); + } else { + scaffoldKey.currentState?.openDrawer(); + } + } + }, + child: Row(children: [Expanded(child: bodyContentWithBackground)]), + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + _showAddMenu(context); + }, + tooltip: 'Создать', + heroTag: 'create_menu', + child: const Icon(Icons.edit), + ), + ); + + return scaffold; }, ); } else { - return bodyContent; + return bodyContentWithBackground; } } @@ -2026,6 +2116,49 @@ class _ChatsScreenState extends State final themeProvider = context.watch(); final isDarkMode = themeProvider.themeMode == ThemeMode.dark; + Widget? _buildBackgroundWidget(ChatWallpaperType type, Color color1, Color color2, String? imagePath) { + switch (type) { + case ChatWallpaperType.solid: + return Container(color: color1); + case ChatWallpaperType.gradient: + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [color1, color2], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + ); + case ChatWallpaperType.image: + if (imagePath != null) { + return Image.file( + File(imagePath), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ); + } + return null; + case ChatWallpaperType.video: + return null; + } + } + + final drawerTopBackground = _buildBackgroundWidget( + themeProvider.drawerTopBackgroundType, + themeProvider.drawerTopBackgroundColor1, + themeProvider.drawerTopBackgroundColor2, + themeProvider.drawerTopBackgroundImagePath, + ); + + final drawerBottomBackground = _buildBackgroundWidget( + themeProvider.drawerBottomBackgroundType, + themeProvider.drawerBottomBackgroundColor1, + themeProvider.drawerBottomBackgroundColor2, + themeProvider.drawerBottomBackgroundImagePath, + ); + return Drawer( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -2038,132 +2171,136 @@ class _ChatsScreenState extends State final currentAccount = accountManager.currentAccount; final hasMultipleAccounts = accounts.length > 1; - return Column( + return Stack( children: [ - Container( - width: double.infinity, - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + 16.0, - left: 16.0, - right: 16.0, - bottom: 16.0, - ), - decoration: BoxDecoration(color: colors.primaryContainer), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + if (drawerTopBackground != null) + Positioned.fill(child: drawerTopBackground), + Column( + children: [ + Container( + width: double.infinity, + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 16.0, + left: 16.0, + right: 16.0, + bottom: 16.0, + ), + decoration: BoxDecoration(color: drawerTopBackground != null ? Colors.transparent : colors.primaryContainer), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - radius: 30, // Чуть крупнее - backgroundColor: colors.primary, - backgroundImage: - _isProfileLoading || - _myProfile?.photoBaseUrl == null - ? null - : NetworkImage(_myProfile!.photoBaseUrl!), - child: _isProfileLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : (_myProfile?.photoBaseUrl == null - ? Text( - _myProfile - ?.displayName - .isNotEmpty == - true - ? _myProfile!.displayName[0] - .toUpperCase() - : '?', - style: TextStyle( - color: colors.onPrimary, - fontSize: 28, // Крупнее - ), - ) - : null), - ), - IconButton( - icon: Icon( - isDarkMode - ? Icons.brightness_7 - : Icons.brightness_4, // Солнце / Луна - color: colors.onPrimaryContainer, - size: 26, - ), - onPressed: () { - themeProvider.toggleTheme(); - }, - tooltip: isDarkMode - ? 'Светлая тема' - : 'Темная тема', - ), - ], - ), - const SizedBox(height: 12), - - Text( - _myProfile?.displayName ?? 'Загрузка...', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: colors.onPrimaryContainer, - ), - ), - const SizedBox(height: 4), - - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - _myProfile?.formattedPhone ?? '', - style: TextStyle( - color: colors.onPrimaryContainer.withOpacity( - 0.8, - ), - fontSize: 14, - ), - ), - ), - InkWell( - onTap: () { - setState(() { - _isAccountsExpanded = !_isAccountsExpanded; - }); - }, - child: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Icon( - _isAccountsExpanded - ? Icons.expand_less - : Icons.expand_more, - color: colors.onPrimaryContainer, - size: 24, - ), - ), - ), - ], - ), - ], - ), - ), - - ClipRect( - child: AnimatedSize( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOutCubic, - child: _isAccountsExpanded - ? Column( + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (hasMultipleAccounts) - ...accounts.map((account) { + CircleAvatar( + radius: 30, // Чуть крупнее + backgroundColor: colors.primary, + backgroundImage: + _isProfileLoading || + _myProfile?.photoBaseUrl == null + ? null + : NetworkImage(_myProfile!.photoBaseUrl!), + child: _isProfileLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : (_myProfile?.photoBaseUrl == null + ? Text( + _myProfile + ?.displayName + .isNotEmpty == + true + ? _myProfile!.displayName[0] + .toUpperCase() + : '?', + style: TextStyle( + color: colors.onPrimary, + fontSize: 28, // Крупнее + ), + ) + : null), + ), + IconButton( + icon: Icon( + isDarkMode + ? Icons.brightness_7 + : Icons.brightness_4, // Солнце / Луна + color: colors.onPrimaryContainer, + size: 26, + ), + onPressed: () { + themeProvider.toggleTheme(); + }, + tooltip: isDarkMode + ? 'Светлая тема' + : 'Темная тема', + ), + ], + ), + const SizedBox(height: 12), + + Text( + _myProfile?.displayName ?? 'Загрузка...', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: colors.onPrimaryContainer, + ), + ), + const SizedBox(height: 4), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + _myProfile?.formattedPhone ?? '', + style: TextStyle( + color: colors.onPrimaryContainer.withOpacity( + 0.8, + ), + fontSize: 14, + ), + ), + ), + InkWell( + onTap: () { + setState(() { + _isAccountsExpanded = !_isAccountsExpanded; + }); + }, + child: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Icon( + _isAccountsExpanded + ? Icons.expand_less + : Icons.expand_more, + color: colors.onPrimaryContainer, + size: 24, + ), + ), + ), + ], + ), + ], + ), + ), + + ClipRect( + child: AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOutCubic, + child: _isAccountsExpanded + ? Column( + children: [ + if (hasMultipleAccounts) + ...accounts.map((account) { final isCurrent = account.id == currentAccount?.id; return ListTile( @@ -2289,16 +2426,22 @@ class _ChatsScreenState extends State ], ) : const SizedBox.shrink(), - ), + ), + ), + ], ), ], ); }, ), Expanded( - child: Column( + child: Stack( children: [ - _buildAccountsSection(context, colors), + if (drawerBottomBackground != null) + Positioned.fill(child: drawerBottomBackground), + Column( + children: [ + _buildAccountsSection(context, colors), ListTile( leading: const Icon(Icons.person_outline), title: const Text('Мой профиль'), @@ -2441,6 +2584,8 @@ class _ChatsScreenState extends State }, ), const SizedBox(height: 8), // Небольшой отступ снизу + ], + ), ], ), ), diff --git a/lib/screens/settings/customization_screen.dart b/lib/screens/settings/customization_screen.dart index e0f9d76..37ef18b 100644 --- a/lib/screens/settings/customization_screen.dart +++ b/lib/screens/settings/customization_screen.dart @@ -535,6 +535,37 @@ class _CustomizationScreenState extends State { ), ), const SizedBox(height: 8), + + _ExpandableSection( + title: "Отступ внизу чата", + initiallyExpanded: false, + children: [ + _SliderTile( + icon: Icons.vertical_align_bottom, + label: "Доп. отступ снизу (мобилы)", + value: theme.mobileChatBottomPadding, + min: 60, + max: 240, + divisions: 18, + onChanged: (value) => + theme.setMobileChatBottomPadding(value), + displayValue: + "${theme.mobileChatBottomPadding.toStringAsFixed(0)} px", + ), + const SizedBox(height: 8), + _CustomSettingTile( + icon: Icons.keyboard, + title: "Убирать отступ при открытой клавиатуре", + subtitle: + "Только Android. Когда вводите текст — отступ снизу не добавляется", + child: Switch( + value: theme.ignoreMobileBottomPaddingWhenKeyboard, + onChanged: (value) => theme + .setIgnoreMobileBottomPaddingWhenKeyboard(value), + ), + ), + ], + ), // Развернуть настройки _ExpandableSection( @@ -582,6 +613,296 @@ class _CustomizationScreenState extends State { ), ], ), + const SizedBox(height: 24), + _ModernSection( + title: "Экспериментальные настройки фона", + children: [ + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colors.errorContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colors.error.withOpacity(0.5), + width: 1, + ), + ), + child: Row( + children: [ + Icon(Icons.science, color: colors.error, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + "Экспериментальные функции. Могут работать нестабильно.", + style: TextStyle( + fontSize: 12, + color: colors.onErrorContainer, + ), + ), + ), + ], + ), + ), + _ExpandableSection( + title: "Фон списка чатов", + initiallyExpanded: false, + children: [ + _CustomSettingTile( + icon: Icons.science, + title: "Использовать экспериментальный фон", + subtitle: "Фон для экрана со списком чатов", + child: Switch( + value: theme.useExperimentalChatsListBackground, + onChanged: (value) => + theme.setUseExperimentalChatsListBackground(value), + ), + ), + if (theme.useExperimentalChatsListBackground) ...[ + const Divider(height: 24), + _CustomSettingTile( + icon: Icons.image, + title: "Тип фона", + child: DropdownButton( + value: theme.experimentalChatsListBackgroundType, + onChanged: (value) { + if (value != null) { + theme.setExperimentalChatsListBackgroundType(value); + } + }, + items: [ + ChatWallpaperType.solid, + ChatWallpaperType.gradient, + ChatWallpaperType.image, + ].map((type) { + return DropdownMenuItem( + value: type, + child: Text(type.displayName), + ); + }).toList(), + ), + ), + if (theme.experimentalChatsListBackgroundType == + ChatWallpaperType.solid || + theme.experimentalChatsListBackgroundType == + ChatWallpaperType.gradient) ...[ + const SizedBox(height: 16), + _ColorPickerTile( + title: "Цвет 1", + subtitle: "Основной цвет", + color: theme.experimentalChatsListBackgroundColor1, + onColorChanged: (color) => + theme.setExperimentalChatsListBackgroundColor1(color), + ), + ], + if (theme.experimentalChatsListBackgroundType == + ChatWallpaperType.gradient) ...[ + const SizedBox(height: 16), + _ColorPickerTile( + title: "Цвет 2", + subtitle: "Дополнительный цвет для градиента", + color: theme.experimentalChatsListBackgroundColor2, + onColorChanged: (color) => + theme.setExperimentalChatsListBackgroundColor2(color), + ), + ], + if (theme.experimentalChatsListBackgroundType == + ChatWallpaperType.image) ...[ + const Divider(height: 24), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.photo_library_outlined), + title: const Text("Выбрать изображение"), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final picker = ImagePicker(); + final image = await picker.pickImage( + source: ImageSource.gallery, + ); + if (image != null) { + theme.setExperimentalChatsListBackgroundImagePath( + image.path, + ); + } + }, + ), + if (theme.experimentalChatsListBackgroundImagePath + ?.isNotEmpty == + true) ...[ + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.delete_outline), + title: const Text("Удалить изображение"), + onTap: () { + theme.setExperimentalChatsListBackgroundImagePath(null); + }, + ), + ], + ], + ], + ], + ), + const SizedBox(height: 16), + _ExpandableSection( + title: "Фон боковой панели - верхняя часть (профиль)", + initiallyExpanded: false, + children: [ + _CustomSettingTile( + icon: Icons.image, + title: "Тип фона", + child: DropdownButton( + value: theme.drawerTopBackgroundType, + onChanged: (value) { + if (value != null) { + theme.setDrawerTopBackgroundType(value); + } + }, + items: [ + ChatWallpaperType.solid, + ChatWallpaperType.gradient, + ChatWallpaperType.image, + ].map((type) { + return DropdownMenuItem( + value: type, + child: Text(type.displayName), + ); + }).toList(), + ), + ), + if (theme.drawerTopBackgroundType == ChatWallpaperType.solid || + theme.drawerTopBackgroundType == ChatWallpaperType.gradient) ...[ + const SizedBox(height: 16), + _ColorPickerTile( + title: "Цвет 1", + subtitle: "Основной цвет", + color: theme.drawerTopBackgroundColor1, + onColorChanged: (color) => + theme.setDrawerTopBackgroundColor1(color), + ), + ], + if (theme.drawerTopBackgroundType == ChatWallpaperType.gradient) ...[ + const SizedBox(height: 16), + _ColorPickerTile( + title: "Цвет 2", + subtitle: "Дополнительный цвет для градиента", + color: theme.drawerTopBackgroundColor2, + onColorChanged: (color) => + theme.setDrawerTopBackgroundColor2(color), + ), + ], + if (theme.drawerTopBackgroundType == ChatWallpaperType.image) ...[ + const Divider(height: 24), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.photo_library_outlined), + title: const Text("Выбрать изображение"), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final picker = ImagePicker(); + final image = await picker.pickImage( + source: ImageSource.gallery, + ); + if (image != null) { + theme.setDrawerTopBackgroundImagePath(image.path); + } + }, + ), + if (theme.drawerTopBackgroundImagePath?.isNotEmpty == true) ...[ + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.delete_outline), + title: const Text("Удалить изображение"), + onTap: () { + theme.setDrawerTopBackgroundImagePath(null); + }, + ), + ], + ], + ], + ), + const SizedBox(height: 16), + _ExpandableSection( + title: "Фон боковой панели - нижняя часть (меню)", + initiallyExpanded: false, + children: [ + _CustomSettingTile( + icon: Icons.image, + title: "Тип фона", + child: DropdownButton( + value: theme.drawerBottomBackgroundType, + onChanged: (value) { + if (value != null) { + theme.setDrawerBottomBackgroundType(value); + } + }, + items: [ + ChatWallpaperType.solid, + ChatWallpaperType.gradient, + ChatWallpaperType.image, + ].map((type) { + return DropdownMenuItem( + value: type, + child: Text(type.displayName), + ); + }).toList(), + ), + ), + if (theme.drawerBottomBackgroundType == ChatWallpaperType.solid || + theme.drawerBottomBackgroundType == ChatWallpaperType.gradient) ...[ + const SizedBox(height: 16), + _ColorPickerTile( + title: "Цвет 1", + subtitle: "Основной цвет", + color: theme.drawerBottomBackgroundColor1, + onColorChanged: (color) => + theme.setDrawerBottomBackgroundColor1(color), + ), + ], + if (theme.drawerBottomBackgroundType == ChatWallpaperType.gradient) ...[ + const SizedBox(height: 16), + _ColorPickerTile( + title: "Цвет 2", + subtitle: "Дополнительный цвет для градиента", + color: theme.drawerBottomBackgroundColor2, + onColorChanged: (color) => + theme.setDrawerBottomBackgroundColor2(color), + ), + ], + if (theme.drawerBottomBackgroundType == ChatWallpaperType.image) ...[ + const Divider(height: 24), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.photo_library_outlined), + title: const Text("Выбрать изображение"), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final picker = ImagePicker(); + final image = await picker.pickImage( + source: ImageSource.gallery, + ); + if (image != null) { + theme.setDrawerBottomBackgroundImagePath(image.path); + } + }, + ), + if (theme.drawerBottomBackgroundImagePath?.isNotEmpty == true) ...[ + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.delete_outline), + title: const Text("Удалить изображение"), + onTap: () { + theme.setDrawerBottomBackgroundImagePath(null); + }, + ), + ], + ], + ], + ), + ], + ), ], ), ); diff --git a/lib/utils/theme_provider.dart b/lib/utils/theme_provider.dart index 8923963..ee70269 100644 --- a/lib/utils/theme_provider.dart +++ b/lib/utils/theme_provider.dart @@ -104,6 +104,25 @@ class CustomThemePreset { bool useDesktopLayout; bool useAutoReplyColor; Color? customReplyColor; + double mobileChatBottomPadding; + bool ignoreMobileBottomPaddingWhenKeyboard; + + // Экспериментальные настройки фона + bool useExperimentalChatsListBackground; + ChatWallpaperType experimentalChatsListBackgroundType; + Color experimentalChatsListBackgroundColor1; + Color experimentalChatsListBackgroundColor2; + String? experimentalChatsListBackgroundImagePath; + + ChatWallpaperType drawerTopBackgroundType; + Color drawerTopBackgroundColor1; + Color drawerTopBackgroundColor2; + String? drawerTopBackgroundImagePath; + + ChatWallpaperType drawerBottomBackgroundType; + Color drawerBottomBackgroundColor1; + Color drawerBottomBackgroundColor2; + String? drawerBottomBackgroundImagePath; CustomThemePreset({ required this.id, @@ -155,6 +174,21 @@ class CustomThemePreset { this.useDesktopLayout = true, this.useAutoReplyColor = true, this.customReplyColor, + this.mobileChatBottomPadding = 140.0, + this.ignoreMobileBottomPaddingWhenKeyboard = true, + this.useExperimentalChatsListBackground = false, + this.experimentalChatsListBackgroundType = ChatWallpaperType.solid, + this.experimentalChatsListBackgroundColor1 = const Color(0xFF101010), + this.experimentalChatsListBackgroundColor2 = const Color(0xFF202020), + this.experimentalChatsListBackgroundImagePath, + this.drawerTopBackgroundType = ChatWallpaperType.solid, + this.drawerTopBackgroundColor1 = const Color(0xFF1E1E1E), + this.drawerTopBackgroundColor2 = const Color(0xFF2E2E2E), + this.drawerTopBackgroundImagePath, + this.drawerBottomBackgroundType = ChatWallpaperType.solid, + this.drawerBottomBackgroundColor1 = const Color(0xFF1E1E1E), + this.drawerBottomBackgroundColor2 = const Color(0xFF2E2E2E), + this.drawerBottomBackgroundImagePath, }); factory CustomThemePreset.createDefault() { @@ -211,6 +245,21 @@ class CustomThemePreset { bool? useDesktopLayout, bool? useAutoReplyColor, Color? customReplyColor, + double? mobileChatBottomPadding, + bool? ignoreMobileBottomPaddingWhenKeyboard, + bool? useExperimentalChatsListBackground, + ChatWallpaperType? experimentalChatsListBackgroundType, + Color? experimentalChatsListBackgroundColor1, + Color? experimentalChatsListBackgroundColor2, + String? experimentalChatsListBackgroundImagePath, + ChatWallpaperType? drawerTopBackgroundType, + Color? drawerTopBackgroundColor1, + Color? drawerTopBackgroundColor2, + String? drawerTopBackgroundImagePath, + ChatWallpaperType? drawerBottomBackgroundType, + Color? drawerBottomBackgroundColor1, + Color? drawerBottomBackgroundColor2, + String? drawerBottomBackgroundImagePath, }) { return CustomThemePreset( id: id ?? this.id, @@ -271,6 +320,35 @@ class CustomThemePreset { useDesktopLayout: useDesktopLayout ?? this.useDesktopLayout, useAutoReplyColor: useAutoReplyColor ?? this.useAutoReplyColor, customReplyColor: customReplyColor ?? this.customReplyColor, + mobileChatBottomPadding: + mobileChatBottomPadding ?? this.mobileChatBottomPadding, + ignoreMobileBottomPaddingWhenKeyboard: + ignoreMobileBottomPaddingWhenKeyboard ?? + this.ignoreMobileBottomPaddingWhenKeyboard, + useExperimentalChatsListBackground: useExperimentalChatsListBackground ?? + this.useExperimentalChatsListBackground, + experimentalChatsListBackgroundType: experimentalChatsListBackgroundType ?? + this.experimentalChatsListBackgroundType, + experimentalChatsListBackgroundColor1: experimentalChatsListBackgroundColor1 ?? + this.experimentalChatsListBackgroundColor1, + experimentalChatsListBackgroundColor2: experimentalChatsListBackgroundColor2 ?? + this.experimentalChatsListBackgroundColor2, + experimentalChatsListBackgroundImagePath: experimentalChatsListBackgroundImagePath ?? + this.experimentalChatsListBackgroundImagePath, + drawerTopBackgroundType: drawerTopBackgroundType ?? this.drawerTopBackgroundType, + drawerTopBackgroundColor1: + drawerTopBackgroundColor1 ?? this.drawerTopBackgroundColor1, + drawerTopBackgroundColor2: + drawerTopBackgroundColor2 ?? this.drawerTopBackgroundColor2, + drawerTopBackgroundImagePath: + drawerTopBackgroundImagePath ?? this.drawerTopBackgroundImagePath, + drawerBottomBackgroundType: drawerBottomBackgroundType ?? this.drawerBottomBackgroundType, + drawerBottomBackgroundColor1: + drawerBottomBackgroundColor1 ?? this.drawerBottomBackgroundColor1, + drawerBottomBackgroundColor2: + drawerBottomBackgroundColor2 ?? this.drawerBottomBackgroundColor2, + drawerBottomBackgroundImagePath: + drawerBottomBackgroundImagePath ?? this.drawerBottomBackgroundImagePath, ); } @@ -325,6 +403,22 @@ class CustomThemePreset { 'useDesktopLayout': useDesktopLayout, 'useAutoReplyColor': useAutoReplyColor, 'customReplyColor': customReplyColor?.value, + 'mobileChatBottomPadding': mobileChatBottomPadding, + 'ignoreMobileBottomPaddingWhenKeyboard': + ignoreMobileBottomPaddingWhenKeyboard, + 'useExperimentalChatsListBackground': useExperimentalChatsListBackground, + 'experimentalChatsListBackgroundType': experimentalChatsListBackgroundType.index, + 'experimentalChatsListBackgroundColor1': experimentalChatsListBackgroundColor1.value, + 'experimentalChatsListBackgroundColor2': experimentalChatsListBackgroundColor2.value, + 'experimentalChatsListBackgroundImagePath': experimentalChatsListBackgroundImagePath, + 'drawerTopBackgroundType': drawerTopBackgroundType.index, + 'drawerTopBackgroundColor1': drawerTopBackgroundColor1.value, + 'drawerTopBackgroundColor2': drawerTopBackgroundColor2.value, + 'drawerTopBackgroundImagePath': drawerTopBackgroundImagePath, + 'drawerBottomBackgroundType': drawerBottomBackgroundType.index, + 'drawerBottomBackgroundColor1': drawerBottomBackgroundColor1.value, + 'drawerBottomBackgroundColor2': drawerBottomBackgroundColor2.value, + 'drawerBottomBackgroundImagePath': drawerBottomBackgroundImagePath, }; } @@ -422,6 +516,44 @@ class CustomThemePreset { customReplyColor: json['customReplyColor'] != null ? Color(json['customReplyColor'] as int) : null, + mobileChatBottomPadding: + (json['mobileChatBottomPadding'] as double? ?? 140.0).clamp( + 60.0, + 240.0, + ), + ignoreMobileBottomPaddingWhenKeyboard: + json['ignoreMobileBottomPaddingWhenKeyboard'] as bool? ?? true, + useExperimentalChatsListBackground: json['useExperimentalChatsListBackground'] as bool? ?? false, + experimentalChatsListBackgroundType: () { + final index = json['experimentalChatsListBackgroundType'] as int?; + if (index == null || index < 0 || index >= ChatWallpaperType.values.length) { + return ChatWallpaperType.solid; + } + return ChatWallpaperType.values[index]; + }(), + experimentalChatsListBackgroundColor1: Color(json['experimentalChatsListBackgroundColor1'] as int? ?? const Color(0xFF101010).value), + experimentalChatsListBackgroundColor2: Color(json['experimentalChatsListBackgroundColor2'] as int? ?? const Color(0xFF202020).value), + experimentalChatsListBackgroundImagePath: json['experimentalChatsListBackgroundImagePath'] as String?, + drawerTopBackgroundType: () { + final index = json['drawerTopBackgroundType'] as int?; + if (index == null || index < 0 || index >= ChatWallpaperType.values.length) { + return ChatWallpaperType.solid; + } + return ChatWallpaperType.values[index]; + }(), + drawerTopBackgroundColor1: Color(json['drawerTopBackgroundColor1'] as int? ?? const Color(0xFF1E1E1E).value), + drawerTopBackgroundColor2: Color(json['drawerTopBackgroundColor2'] as int? ?? const Color(0xFF2E2E2E).value), + drawerTopBackgroundImagePath: json['drawerTopBackgroundImagePath'] as String?, + drawerBottomBackgroundType: () { + final index = json['drawerBottomBackgroundType'] as int?; + if (index == null || index < 0 || index >= ChatWallpaperType.values.length) { + return ChatWallpaperType.solid; + } + return ChatWallpaperType.values[index]; + }(), + drawerBottomBackgroundColor1: Color(json['drawerBottomBackgroundColor1'] as int? ?? const Color(0xFF1E1E1E).value), + drawerBottomBackgroundColor2: Color(json['drawerBottomBackgroundColor2'] as int? ?? const Color(0xFF2E2E2E).value), + drawerBottomBackgroundImagePath: json['drawerBottomBackgroundImagePath'] as String?, ); } } @@ -529,6 +661,26 @@ class ThemeProvider with ChangeNotifier { bool get debugShowMessageCount => _debugShowMessageCount; bool get debugReadOnEnter => _debugReadOnEnter; bool get debugReadOnAction => _debugReadOnAction; + double get mobileChatBottomPadding => _activeTheme.mobileChatBottomPadding; + bool get ignoreMobileBottomPaddingWhenKeyboard => + _activeTheme.ignoreMobileBottomPaddingWhenKeyboard; + + // Экспериментальные настройки фона + bool get useExperimentalChatsListBackground => _activeTheme.useExperimentalChatsListBackground; + ChatWallpaperType get experimentalChatsListBackgroundType => _activeTheme.experimentalChatsListBackgroundType; + Color get experimentalChatsListBackgroundColor1 => _activeTheme.experimentalChatsListBackgroundColor1; + Color get experimentalChatsListBackgroundColor2 => _activeTheme.experimentalChatsListBackgroundColor2; + String? get experimentalChatsListBackgroundImagePath => _activeTheme.experimentalChatsListBackgroundImagePath; + + ChatWallpaperType get drawerTopBackgroundType => _activeTheme.drawerTopBackgroundType; + Color get drawerTopBackgroundColor1 => _activeTheme.drawerTopBackgroundColor1; + Color get drawerTopBackgroundColor2 => _activeTheme.drawerTopBackgroundColor2; + String? get drawerTopBackgroundImagePath => _activeTheme.drawerTopBackgroundImagePath; + + ChatWallpaperType get drawerBottomBackgroundType => _activeTheme.drawerBottomBackgroundType; + Color get drawerBottomBackgroundColor1 => _activeTheme.drawerBottomBackgroundColor1; + Color get drawerBottomBackgroundColor2 => _activeTheme.drawerBottomBackgroundColor2; + String? get drawerBottomBackgroundImagePath => _activeTheme.drawerBottomBackgroundImagePath; TransitionOption get chatTransition => _activeTheme.ultraOptimizeChats ? TransitionOption.systemDefault @@ -1212,6 +1364,101 @@ class ThemeProvider with ChangeNotifier { await _saveActiveTheme(); } + Future setMobileChatBottomPadding(double value) async { + final clamped = value.clamp(60.0, 240.0); + _activeTheme = _activeTheme.copyWith( + mobileChatBottomPadding: clamped, + ); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setIgnoreMobileBottomPaddingWhenKeyboard(bool value) async { + _activeTheme = _activeTheme.copyWith( + ignoreMobileBottomPaddingWhenKeyboard: value, + ); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setUseExperimentalChatsListBackground(bool value) async { + _activeTheme = _activeTheme.copyWith(useExperimentalChatsListBackground: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setExperimentalChatsListBackgroundType(ChatWallpaperType value) async { + _activeTheme = _activeTheme.copyWith(experimentalChatsListBackgroundType: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setExperimentalChatsListBackgroundColor1(Color value) async { + _activeTheme = _activeTheme.copyWith(experimentalChatsListBackgroundColor1: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setExperimentalChatsListBackgroundColor2(Color value) async { + _activeTheme = _activeTheme.copyWith(experimentalChatsListBackgroundColor2: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setExperimentalChatsListBackgroundImagePath(String? path) async { + _activeTheme = _activeTheme.copyWith(experimentalChatsListBackgroundImagePath: path); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setDrawerTopBackgroundType(ChatWallpaperType value) async { + _activeTheme = _activeTheme.copyWith(drawerTopBackgroundType: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setDrawerTopBackgroundColor1(Color value) async { + _activeTheme = _activeTheme.copyWith(drawerTopBackgroundColor1: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setDrawerTopBackgroundColor2(Color value) async { + _activeTheme = _activeTheme.copyWith(drawerTopBackgroundColor2: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setDrawerTopBackgroundImagePath(String? path) async { + _activeTheme = _activeTheme.copyWith(drawerTopBackgroundImagePath: path); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setDrawerBottomBackgroundType(ChatWallpaperType value) async { + _activeTheme = _activeTheme.copyWith(drawerBottomBackgroundType: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setDrawerBottomBackgroundColor1(Color value) async { + _activeTheme = _activeTheme.copyWith(drawerBottomBackgroundColor1: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setDrawerBottomBackgroundColor2(Color value) async { + _activeTheme = _activeTheme.copyWith(drawerBottomBackgroundColor2: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setDrawerBottomBackgroundImagePath(String? path) async { + _activeTheme = _activeTheme.copyWith(drawerBottomBackgroundImagePath: path); + notifyListeners(); + await _saveActiveTheme(); + } + Future setCustomReplyColor(Color? color) async { _activeTheme = _activeTheme.copyWith(customReplyColor: color); notifyListeners();