From ccc93f9e6204801b5e8a96d0cfa4abf7dc3f2b31 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 28 May 2026 00:07:27 +0700 Subject: [PATCH] =?UTF-8?q?=D0=B6=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/app_swipe_back_desktop.dart | 20 +++ .../screens/chats/chat_list_screen.dart | 15 +- lib/frontend/screens/chats/chat_screen.dart | 14 +- .../screens/chats/create_group_flow.dart | 16 +- .../contacts/contact_profile_screen.dart | 15 +- .../screens/profile/debug_menu_screen.dart | 65 ++++++++ lib/frontend/widgets/swipe_route.dart | 20 +++ lib/frontend/widgets/swipe_to_pop.dart | 141 ++++++++++++++++++ lib/main.dart | 2 + 9 files changed, 282 insertions(+), 26 deletions(-) create mode 100644 lib/core/config/app_swipe_back_desktop.dart create mode 100644 lib/frontend/widgets/swipe_route.dart create mode 100644 lib/frontend/widgets/swipe_to_pop.dart diff --git a/lib/core/config/app_swipe_back_desktop.dart b/lib/core/config/app_swipe_back_desktop.dart new file mode 100644 index 0000000..9dffd1f --- /dev/null +++ b/lib/core/config/app_swipe_back_desktop.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppSwipeBackDesktop { + static const prefKey = 'dev_swipe_back_desktop'; + static const bool defaultValue = false; + + static final ValueNotifier current = ValueNotifier(defaultValue); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(prefKey) ?? defaultValue; + } + + static Future save(bool value) async { + current.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(prefKey, value); + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index adbdefa..655e5f6 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -10,6 +10,7 @@ import 'chat_screen.dart'; import 'create_group_flow.dart'; import '../../widgets/adaptive_shell.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/swipe_route.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; @@ -2072,15 +2073,13 @@ class _ChatListScreenState extends State chatType: chatType, )); } else { - Navigator.push( + pushSwipeable( context, - MaterialPageRoute( - builder: (context) => ChatScreen( - chatId: int.parse(id), - name: name, - imageUrl: imageUrl, - chatType: chatType, - ), + (context) => ChatScreen( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, ), ); } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 9a2d69d..6dcd73d 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -21,10 +21,12 @@ import '../../../core/storage/app_database.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/config/app_cache_extent.dart'; import '../../../core/config/app_message_actions_style.dart'; +import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../models/attachment.dart'; import '../../widgets/message_bubble.dart'; import '../../widgets/message_actions_overlay.dart'; import '../../widgets/attachment_panel.dart'; +import '../../widgets/swipe_to_pop.dart'; class _UploadStatus { final bool active; @@ -791,10 +793,17 @@ class _ChatScreenState extends State @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - + // TODO: Локализация // TODO: Cклонения - return Scaffold( + return ValueListenableBuilder( + valueListenable: AppSwipeBackDesktop.current, + builder: (context, desktopSwipe, child) => SwipeToPop( + enabled: widget.embedded && desktopSwipe, + onPop: widget.onClose, + child: child!, + ), + child: Scaffold( backgroundColor: cs.surface, appBar: PreferredSize( preferredSize: Size.fromHeight(kToolbarHeight), @@ -942,6 +951,7 @@ class _ChatScreenState extends State _buildInputArea(context), ], ), + ), ); } diff --git a/lib/frontend/screens/chats/create_group_flow.dart b/lib/frontend/screens/chats/create_group_flow.dart index fed22a6..3f6b3ea 100644 --- a/lib/frontend/screens/chats/create_group_flow.dart +++ b/lib/frontend/screens/chats/create_group_flow.dart @@ -10,6 +10,7 @@ import '../../../backend/modules/contacts.dart'; import '../../../core/storage/token_storage.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/swipe_route.dart'; import 'chat_screen.dart'; const int _maxAvatarBytes = 8 * 1024 * 1024; @@ -146,14 +147,13 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { if (!mounted) return; navigator.pop(); - navigator.push( - MaterialPageRoute( - builder: (_) => ChatScreen( - chatId: chat.id, - name: chat.title ?? title, - imageUrl: chat.iconUrl ?? '', - chatType: chat.type, - ), + pushSwipeable( + context, + (_) => ChatScreen( + chatId: chat.id, + name: chat.title ?? title, + imageUrl: chat.iconUrl ?? '', + chatType: chat.type, ), ); } catch (e) { diff --git a/lib/frontend/screens/contacts/contact_profile_screen.dart b/lib/frontend/screens/contacts/contact_profile_screen.dart index e0051b4..e502a18 100644 --- a/lib/frontend/screens/contacts/contact_profile_screen.dart +++ b/lib/frontend/screens/contacts/contact_profile_screen.dart @@ -7,6 +7,7 @@ import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/swipe_route.dart'; import '../chats/chat_screen.dart'; class ContactProfileScreen extends StatefulWidget { @@ -170,15 +171,13 @@ class _ContactProfileScreenState extends State { ); final chatId = existing ?? (accountId ^ widget.contactId); if (!mounted) return; - Navigator.push( + pushSwipeable( context, - MaterialPageRoute( - builder: (_) => ChatScreen( - chatId: chatId, - name: _displayName(), - imageUrl: _avatarUrl() ?? '', - chatType: 'DIALOG', - ), + (_) => ChatScreen( + chatId: chatId, + name: _displayName(), + imageUrl: _avatarUrl() ?? '', + chatType: 'DIALOG', ), ); } diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index ed594bc..b607079 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/chats.dart'; +import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/utils/logger.dart'; @@ -332,6 +333,70 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: ValueListenableBuilder( + valueListenable: AppSwipeBackDesktop.current, + builder: (context, swipeOn, _) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.swipe_right, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Свайп-назад в десктоп-режиме', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Включает жест «провести от левого края, чтобы ' + 'закрыть» внутри встроенной панели чата на ' + 'десктопе — для тестирования курсором', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Switch( + value: swipeOn, + onChanged: (v) { + AppSwipeBackDesktop.save(v); + }, + ), + ], + ), + ), + ); + }, + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), diff --git a/lib/frontend/widgets/swipe_route.dart b/lib/frontend/widgets/swipe_route.dart new file mode 100644 index 0000000..933ad50 --- /dev/null +++ b/lib/frontend/widgets/swipe_route.dart @@ -0,0 +1,20 @@ +import 'package:flutter/cupertino.dart'; + +class SwipeRoute extends CupertinoPageRoute { + SwipeRoute({ + required super.builder, + super.settings, + super.maintainState, + super.fullscreenDialog, + }); +} + +Future pushSwipeable( + BuildContext context, + WidgetBuilder builder, { + RouteSettings? settings, +}) { + return Navigator.of(context).push( + SwipeRoute(builder: builder, settings: settings), + ); +} diff --git a/lib/frontend/widgets/swipe_to_pop.dart b/lib/frontend/widgets/swipe_to_pop.dart new file mode 100644 index 0000000..3c14093 --- /dev/null +++ b/lib/frontend/widgets/swipe_to_pop.dart @@ -0,0 +1,141 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +class SwipeToPop extends StatefulWidget { + final Widget child; + final double edgeWidth; + final double popThreshold; + final double velocityThreshold; + final bool fullWidth; + final bool enabled; + final VoidCallback? onPop; + + const SwipeToPop({ + super.key, + required this.child, + this.edgeWidth = 28, + this.popThreshold = 0.35, + this.velocityThreshold = 700, + this.fullWidth = false, + this.enabled = true, + this.onPop, + }); + + @override + State createState() => _SwipeToPopState(); +} + +class _SwipeToPopState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 240), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + bool get _canPop { + if (!widget.enabled) return false; + if (widget.onPop != null) return true; + final route = ModalRoute.of(context); + if (route == null) return false; + return route.canPop; + } + + void _onDragStart(DragStartDetails _) { + _controller.stop(); + } + + void _onDragUpdate(DragUpdateDetails d, double width) { + if (width <= 0) return; + final next = (_controller.value + d.delta.dx / width).clamp(0.0, 1.0); + _controller.value = next; + } + + Future _onDragEnd(DragEndDetails d, double width) async { + final velocity = d.primaryVelocity ?? 0; + final pastThreshold = _controller.value > widget.popThreshold || + velocity > widget.velocityThreshold; + if (pastThreshold) { + final remaining = 1.0 - _controller.value; + _controller.duration = Duration( + milliseconds: (remaining * 220).clamp(80, 220).round(), + ); + await _controller.animateTo(1.0, curve: Curves.easeOutCubic); + if (!mounted) return; + if (widget.onPop != null) { + widget.onPop!(); + } else { + Navigator.of(context).maybePop(); + } + } else { + _controller.duration = const Duration(milliseconds: 200); + await _controller.animateBack(0.0, curve: Curves.easeOutCubic); + } + } + + void _onDragCancel() { + _controller.duration = const Duration(milliseconds: 200); + _controller.animateBack(0.0, curve: Curves.easeOutCubic); + } + + @override + Widget build(BuildContext context) { + if (!_canPop) return widget.child; + + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final gestureChild = GestureDetector( + behavior: HitTestBehavior.translucent, + dragStartBehavior: DragStartBehavior.down, + onHorizontalDragStart: _onDragStart, + onHorizontalDragUpdate: (d) => _onDragUpdate(d, width), + onHorizontalDragEnd: (d) => _onDragEnd(d, width), + onHorizontalDragCancel: _onDragCancel, + ); + + return Stack( + children: [ + Positioned.fill( + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final t = _controller.value; + return Transform.translate( + offset: Offset(t * width, 0), + child: Opacity( + opacity: (1.0 - t * 0.35).clamp(0.0, 1.0), + child: child, + ), + ); + }, + child: widget.child, + ), + ), + if (widget.fullWidth) + Positioned.fill(child: gestureChild) + else + Positioned( + left: 0, + top: 0, + bottom: 0, + width: widget.edgeWidth, + child: gestureChild, + ), + ], + ); + }, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 74485e1..905dedb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,7 @@ import 'core/config/app_bubble_shape.dart'; import 'core/config/app_cache_extent.dart'; import 'core/config/app_fonts.dart'; import 'core/config/app_message_actions_style.dart'; +import 'core/config/app_swipe_back_desktop.dart'; import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_schedule.dart'; import 'backend/modules/account.dart'; @@ -92,6 +93,7 @@ void main() async { AppAmoled.current.value = await AppAmoled.load(); AppThemeSchedule.current.value = await AppThemeSchedule.load(); AppMessageActionsStyle.current.value = await AppMessageActionsStyle.load(); + AppSwipeBackDesktop.current.value = await AppSwipeBackDesktop.load(); runApp( KometApp( initialLocale: initialLocale,