починил выход, наконец-то. Сделал недо жесты, добавил эксперементалбные функции замены цвета у боковой панели и списка чатов, добавил настройку отступа сообщений на андроедах а то может у кого то ломаться

This commit is contained in:
jganenok
2025-12-04 22:40:05 +07:00
parent a104428a69
commit 25692659bd
4 changed files with 1334 additions and 589 deletions

View File

@@ -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<ChatScreen> {
Widget build(BuildContext context) {
final theme = context.watch<ThemeProvider>();
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<Offset>(
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<Offset>(
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<double>(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<double>(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<String, dynamic>) {
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<String, dynamic>?;
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<ThemeProvider>()
.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<ThemeProvider>()
.useAutoReplyColor,
customReplyColor: context
.read<ThemeProvider>()
.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<double>(
duration: const Duration(
milliseconds: 600,
),
tween: Tween<double>(
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<double>(
duration: const Duration(
milliseconds: 400,
),
tween: Tween<double>(
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<String, dynamic>) {
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<String, dynamic>?;
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<ThemeProvider>()
.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<ThemeProvider>()
.useAutoReplyColor,
customReplyColor: context
.read<ThemeProvider>()
.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<double>(
duration: const Duration(
milliseconds: 300,
milliseconds: 600,
),
tween: Tween<double>(
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<double>(
duration: const Duration(
milliseconds: 400,
),
tween: Tween<double>(
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<double>(
duration: const Duration(milliseconds: 300),
tween: Tween<double>(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,
),
);
}

View File

@@ -1997,13 +1997,63 @@ class _ChatsScreenState extends State<ChatsScreen>
],
);
final themeProvider = context.watch<ThemeProvider>();
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<ChatsScreen>
child: const Icon(Icons.edit),
),
);
if (!isMobile) return scaffold;
final scaffoldKey = GlobalKey<ScaffoldState>();
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<ChatsScreen>
final themeProvider = context.watch<ThemeProvider>();
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<ChatsScreen>
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<ChatsScreen>
],
)
: 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<ChatsScreen>
},
),
const SizedBox(height: 8), // Небольшой отступ снизу
],
),
],
),
),

View File

@@ -535,6 +535,37 @@ class _CustomizationScreenState extends State<CustomizationScreen> {
),
),
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<CustomizationScreen> {
),
],
),
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<ChatWallpaperType>(
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<ChatWallpaperType>(
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<ChatWallpaperType>(
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);
},
),
],
],
],
),
],
),
],
),
);