feat: десктоп-режим — 2-pane с ресайз-разделителем при ширине окна ≥ 900px

This commit is contained in:
klockky
2026-05-26 17:32:29 +03:00
parent 177989b7dd
commit 8a9832c85b
5 changed files with 260 additions and 17 deletions
@@ -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<DesktopChatSelection>? onChatSelected;
const ChatListScreen({super.key, this.onChatSelected});
@override
State<ChatListScreen> createState() => _ChatListScreenState();
@@ -2050,18 +2053,25 @@ class _ChatListScreenState extends State<ChatListScreen>
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),
+15 -2
View File
@@ -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<ChatScreen>
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(
+220
View File
@@ -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<AdaptiveShell> 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<AdaptiveShell> {
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<void> _loadListWidth() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getDouble(_prefsKey);
if (saved == null || !mounted) return;
setState(() {
_listWidth = saved.clamp(_minListWidth, _maxListWidth);
});
}
Future<void> _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<double> onDrag;
final Future<void> 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,
),
),
],
),
),
);
}
}
@@ -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<ImageProvider?> precacheLoginAvatar(
BuildContext context,
@@ -118,7 +118,7 @@ class _LoginSuccessScreenState extends State<LoginSuccessScreen>
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 360),
reverseTransitionDuration: const Duration(milliseconds: 200),
pageBuilder: (_, __, ___) => const ChatListScreen(),
pageBuilder: (_, __, ___) => const AdaptiveShell(),
transitionsBuilder: (_, animation, __, child) {
return FadeTransition(
opacity: CurvedAnimation(
+2 -2
View File
@@ -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()),
);
}
}