diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 60b4d53..ec16901 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -8,6 +8,7 @@ import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'chat_screen.dart'; import 'create_group_flow.dart'; +import '../../widgets/adaptive_shell.dart'; import '../../widgets/custom_notification.dart'; import '../calls/calls_tab.dart'; @@ -56,7 +57,9 @@ class _StoriesScrollPhysics extends BouncingScrollPhysics { } class ChatListScreen extends StatefulWidget { - const ChatListScreen({super.key}); + final ValueChanged? onChatSelected; + + const ChatListScreen({super.key, this.onChatSelected}); @override State createState() => _ChatListScreenState(); @@ -2050,18 +2053,25 @@ class _ChatListScreenState extends State onTap: () { if (_isSelectionMode) { _toggleSelection(id); + } else if (widget.onChatSelected != null) { + widget.onChatSelected!(DesktopChatSelection( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, + )); } else { -Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChatScreen( - chatId: int.parse(id), - name: name, - imageUrl: imageUrl, - chatType: chatType, - ), + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, ), - ); + ), + ); } }, onLongPress: () => _toggleSelection(id), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 4b9fa90..4f26dce 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -58,6 +58,8 @@ class ChatScreen extends StatefulWidget { final String name; final String imageUrl; final String chatType; + final bool embedded; + final VoidCallback? onClose; const ChatScreen({ super.key, @@ -65,6 +67,8 @@ class ChatScreen extends StatefulWidget { required this.name, required this.imageUrl, required this.chatType, + this.embedded = false, + this.onClose, }); @override @@ -712,8 +716,17 @@ class _ChatScreenState extends State surfaceTintColor: Colors.transparent, iconTheme: IconThemeData(color: cs.onSurface), leading: IconButton( - icon: const Icon(Symbols.arrow_back, weight: 400), - onPressed: () => Navigator.pop(context), + icon: Icon( + widget.embedded ? Symbols.close : Symbols.arrow_back, + weight: 400, + ), + onPressed: () { + if (widget.embedded) { + widget.onClose?.call(); + } else { + Navigator.pop(context); + } + }, ), titleSpacing: 0, title: Row( diff --git a/lib/frontend/widgets/adaptive_shell.dart b/lib/frontend/widgets/adaptive_shell.dart new file mode 100644 index 0000000..c708845 --- /dev/null +++ b/lib/frontend/widgets/adaptive_shell.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../screens/chats/chat_list_screen.dart'; +import '../screens/chats/chat_screen.dart'; + +class AdaptiveShell extends StatefulWidget { + const AdaptiveShell({super.key}); + + @override + State createState() => _AdaptiveShellState(); +} + +class DesktopChatSelection { + final int chatId; + final String name; + final String imageUrl; + final String chatType; + + const DesktopChatSelection({ + required this.chatId, + required this.name, + required this.imageUrl, + required this.chatType, + }); +} + +class _AdaptiveShellState extends State { + static const double _breakpoint = 900; + static const double _defaultListWidth = 380; + static const double _minListWidth = 280; + static const double _maxListWidth = 560; + static const double _minChatPaneWidth = 360; + static const double _dividerHitWidth = 10; + static const double _dividerLineWidth = 1; + static const String _prefsKey = 'desktop_list_width'; + + double _listWidth = _defaultListWidth; + DesktopChatSelection? _selected; + + @override + void initState() { + super.initState(); + _loadListWidth(); + } + + Future _loadListWidth() async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getDouble(_prefsKey); + if (saved == null || !mounted) return; + setState(() { + _listWidth = saved.clamp(_minListWidth, _maxListWidth); + }); + } + + Future _persistListWidth() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_prefsKey, _listWidth); + } + + void _onChatSelected(DesktopChatSelection chat) { + setState(() => _selected = chat); + } + + void _closeChat() { + setState(() => _selected = null); + } + + void _onDrag(double dx, double totalWidth) { + final maxAllowedByPane = + totalWidth - _minChatPaneWidth - _dividerHitWidth; + final upperBound = maxAllowedByPane < _maxListWidth + ? maxAllowedByPane + : _maxListWidth; + final lower = _minListWidth; + final next = (_listWidth + dx).clamp(lower, upperBound); + if (next == _listWidth) return; + setState(() => _listWidth = next); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < _breakpoint) { + return ChatListScreen(onChatSelected: _onChatSelected); + } + final totalWidth = constraints.maxWidth; + final effectiveListWidth = _listWidth.clamp( + _minListWidth, + (totalWidth - _minChatPaneWidth - _dividerHitWidth) + .clamp(_minListWidth, _maxListWidth), + ); + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + body: Row( + children: [ + SizedBox( + width: effectiveListWidth, + child: ChatListScreen(onChatSelected: _onChatSelected), + ), + _ResizeDivider( + hitWidth: _dividerHitWidth, + lineWidth: _dividerLineWidth, + color: cs.outlineVariant.withValues(alpha: 0.35), + onDrag: (dx) => _onDrag(dx, totalWidth), + onDragEnd: _persistListWidth, + ), + Expanded( + child: _selected == null + ? _EmptyChatPane(colorScheme: cs) + : ChatScreen( + key: ValueKey(_selected!.chatId), + chatId: _selected!.chatId, + name: _selected!.name, + imageUrl: _selected!.imageUrl, + chatType: _selected!.chatType, + embedded: true, + onClose: _closeChat, + ), + ), + ], + ), + ); + }, + ); + } +} + +class _ResizeDivider extends StatefulWidget { + final double hitWidth; + final double lineWidth; + final Color color; + final ValueChanged onDrag; + final Future Function() onDragEnd; + + const _ResizeDivider({ + required this.hitWidth, + required this.lineWidth, + required this.color, + required this.onDrag, + required this.onDragEnd, + }); + + @override + State<_ResizeDivider> createState() => _ResizeDividerState(); +} + +class _ResizeDividerState extends State<_ResizeDivider> { + bool _hovering = false; + bool _dragging = false; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final highlight = _dragging || _hovering; + return MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (_) => setState(() => _dragging = true), + onHorizontalDragUpdate: (d) => widget.onDrag(d.delta.dx), + onHorizontalDragEnd: (_) async { + setState(() => _dragging = false); + await widget.onDragEnd(); + }, + onHorizontalDragCancel: () => setState(() => _dragging = false), + child: SizedBox( + width: widget.hitWidth, + child: Center( + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + width: widget.lineWidth, + color: highlight ? cs.primary.withValues(alpha: 0.6) : widget.color, + ), + ), + ), + ), + ); + } +} + +class _EmptyChatPane extends StatelessWidget { + final ColorScheme colorScheme; + + const _EmptyChatPane({required this.colorScheme}); + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: colorScheme.surfaceContainerLow, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Symbols.chat_bubble, + size: 56, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + weight: 300, + ), + const SizedBox(height: 14), + Text( + 'Выберите чат', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/login_success_screen.dart b/lib/frontend/widgets/login_success_screen.dart index fa35339..9617e37 100644 --- a/lib/frontend/widgets/login_success_screen.dart +++ b/lib/frontend/widgets/login_success_screen.dart @@ -4,7 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../core/utils/haptics.dart'; -import '../screens/chats/chat_list_screen.dart'; +import 'adaptive_shell.dart'; Future precacheLoginAvatar( BuildContext context, @@ -118,7 +118,7 @@ class _LoginSuccessScreenState extends State PageRouteBuilder( transitionDuration: const Duration(milliseconds: 360), reverseTransitionDuration: const Duration(milliseconds: 200), - pageBuilder: (_, __, ___) => const ChatListScreen(), + pageBuilder: (_, __, ___) => const AdaptiveShell(), transitionsBuilder: (_, animation, __, child) { return FadeTransition( opacity: CurvedAnimation( diff --git a/lib/main.dart b/lib/main.dart index cbf9d76..74485e1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -33,7 +33,7 @@ import 'core/utils/haptics.dart'; import 'core/protocol/packet.dart'; import 'frontend/debug/fps_overlay_layer.dart'; import 'frontend/screens/auth/login_screen.dart'; -import 'frontend/screens/chats/chat_list_screen.dart'; +import 'frontend/widgets/adaptive_shell.dart'; import 'frontend/widgets/custom_notification.dart'; import 'frontend/widgets/theme_reveal.dart'; @@ -694,7 +694,7 @@ class _StartupScreenState extends State<_StartupScreen> { if (mounted) { Navigator.pushReplacement( context, - MaterialPageRoute(builder: (_) => const ChatListScreen()), + MaterialPageRoute(builder: (_) => const AdaptiveShell()), ); } }