feat: десктоп-режим — 2-pane с ресайз-разделителем при ширине окна ≥ 900px
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user