From 7d3c4f964a9cd1d98894f2815504ae9da7a1ac82 Mon Sep 17 00:00:00 2001 From: RarDog Date: Sat, 5 Sep 2026 17:44:53 +0300 Subject: [PATCH] feat: desktop UI redesign and comprehensive PC hotkeys (v3.6.0) --- lib/app/changelog.dart | 12 + .../feed/presentation/feed_screen.dart | 32 +- .../presentation/widgets/feed_toolbar.dart | 344 +++++++++--- .../presentation/post_details_screen.dart | 2 + .../widgets/post_media_viewer.dart | 386 +++++++++++--- lib/shared/widgets/app_shell.dart | 494 +++++++++++++++--- .../widgets/desktop_shortcuts_dialog.dart | 321 ++++++++++++ pubspec.yaml | 2 +- 8 files changed, 1367 insertions(+), 226 deletions(-) create mode 100644 lib/shared/widgets/desktop_shortcuts_dialog.dart diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 9ad918c..167e76c 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,16 @@ const prismaChangelog = [ + PrismaChange( + version: '3.6.0', + title: 'Редизайн интерфейса для ПК и система горячих клавиш (хоткеи)', + bullets: [ + 'Горячие клавиши для всех вкладок: переключение разделов по Ctrl+1..8 (и Alt+1..8), циклический переход Ctrl+Tab / Ctrl+Shift+Tab, поиск Ctrl+F, обновление Ctrl+R / F5.', + 'Хоткеи видеоплеера: пауза/воспроизведение (Space, K), плавная перемотка на 5с (←/→, J/L) и 15с (Shift+←/→) с визуальными индикаторами, регулировка громкости ↑/↓, выключение звука M, полный экран F.', + 'Хоткеи просмотра фото и галереи: масштабирование клавишами + / - / 0 (сброс в 100%), переключение страниц и фото в постах стрелками ← / →, быстрое сохранение Ctrl+S.', + 'Окно-шпаргалка горячих клавиш: интерактивное модальное окно со всеми клавиатурными комбинациями по нажатию ? или F1, а также из меню панели.', + 'Редизайн боковой панели ПК: боковое меню в стиле Liquid Glass с эффектом матового стекла, логотипом Lunaris, бейджами хоткеев, закреплением панели и быстрыми действиями.', + 'Редизайн вкладок ленты: стильный сегментированный бар выбора периодов (Все время, 24ч, Неделя, Месяц, Год) с анимированными иконками и неоновые чипы источников.', + ], + ), PrismaChange( version: '3.5.5', title: 'Сохранение последней вкладки и состояния навигации между разделами', diff --git a/lib/features/feed/presentation/feed_screen.dart b/lib/features/feed/presentation/feed_screen.dart index 2ac2573..47c5f76 100644 --- a/lib/features/feed/presentation/feed_screen.dart +++ b/lib/features/feed/presentation/feed_screen.dart @@ -144,7 +144,11 @@ class _FeedScreenState extends ConsumerState return Shortcuts( shortcuts: { - LogicalKeySet(LogicalKeyboardKey.keyR): const _RefreshIntent(), + LogicalKeySet(LogicalKeyboardKey.keyR): const _RandomPostIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyR): + const _RefreshIntent(), + LogicalKeySet(LogicalKeyboardKey.f5): const _RefreshIntent(), + LogicalKeySet(LogicalKeyboardKey.keyV): const _ToggleSelectionIntent(), LogicalKeySet(LogicalKeyboardKey.escape): const _ClearSelectionIntent(), LogicalKeySet(LogicalKeyboardKey.delete): const _ClearSelectionIntent(), LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA): @@ -158,6 +162,24 @@ class _FeedScreenState extends ConsumerState return null; }, ), + _RandomPostIntent: CallbackAction<_RandomPostIntent>( + onInvoke: (_) { + final posts = feed.value?.posts; + if (posts != null && posts.isNotEmpty) { + _openRandom(posts); + } + return null; + }, + ), + _ToggleSelectionIntent: CallbackAction<_ToggleSelectionIntent>( + onInvoke: (_) { + setState(() { + _selectionMode = !_selectionMode; + if (!_selectionMode) _selectedKeys.clear(); + }); + return null; + }, + ), _ClearSelectionIntent: CallbackAction<_ClearSelectionIntent>( onInvoke: (_) { _clearSelection(); @@ -682,6 +704,14 @@ class _SelectVisibleIntent extends Intent { const _SelectVisibleIntent(); } +class _RandomPostIntent extends Intent { + const _RandomPostIntent(); +} + +class _ToggleSelectionIntent extends Intent { + const _ToggleSelectionIntent(); +} + class _BatchActionBar extends StatelessWidget { const _BatchActionBar({ required this.count, diff --git a/lib/features/feed/presentation/widgets/feed_toolbar.dart b/lib/features/feed/presentation/widgets/feed_toolbar.dart index d958e85..8d0457b 100644 --- a/lib/features/feed/presentation/widgets/feed_toolbar.dart +++ b/lib/features/feed/presentation/widgets/feed_toolbar.dart @@ -110,7 +110,7 @@ class _FeedToolbarState extends State { ), const SizedBox(width: 8), IconButton.filledTonal( - tooltip: widget.selectionMode ? 'Выйти из выбора' : 'Выбрать посты', + tooltip: widget.selectionMode ? 'Выйти из выбора (Esc)' : 'Выбрать посты (V)', onPressed: widget.onToggleSelectionMode, icon: Icon(widget.selectionMode ? Icons.check_box_rounded @@ -139,13 +139,13 @@ class _FeedToolbarState extends State { ), const SizedBox(width: 4), IconButton( - tooltip: 'Обновить', + tooltip: 'Обновить (Ctrl+R / F5)', onPressed: widget.onRefresh, icon: const Icon(Icons.refresh_rounded), ), const SizedBox(width: 4), IconButton.filledTonal( - tooltip: 'Случайный пост', + tooltip: 'Случайный пост (R)', onPressed: widget.onRandom, icon: const Icon(Icons.casino_rounded), ), @@ -163,41 +163,36 @@ class _FeedToolbarState extends State { const SizedBox(height: 8), ], _HorizontalWheelScroller( - height: 36, + height: 38, builder: (controller) => ListView( controller: controller, scrollDirection: Axis.horizontal, primary: false, children: [ + _LiquidPeriodTabs( + selectedPeriod: widget.topPeriodFilter, + onChanged: widget.onTopPeriodChanged, + ), + const SizedBox(width: 8), + const _ToolbarDivider(), + const SizedBox(width: 8), if (widget.providers.isNotEmpty) ...[ - FilterChip( - selected: widget.selectedProviderIds.isEmpty, - label: const Text('Все источники'), - onSelected: (_) => widget.onQuickProviderToggle('__all__'), + _LiquidProviderPill( + label: 'Все источники', + isSelected: widget.selectedProviderIds.isEmpty, + icon: Icons.all_inclusive_rounded, + onTap: () => widget.onQuickProviderToggle('__all__'), ), - const SizedBox(width: 8), + const SizedBox(width: 6), for (final provider in widget.providers) ...[ - FilterChip( - selected: - widget.selectedProviderIds.contains(provider.id), - avatar: const Icon(Icons.hub_rounded, size: 15), - label: Text(provider.name), - onSelected: (_) => - widget.onQuickProviderToggle(provider.id), + _LiquidProviderPill( + label: provider.name, + isSelected: widget.selectedProviderIds.contains(provider.id), + icon: Icons.hub_rounded, + onTap: () => widget.onQuickProviderToggle(provider.id), ), - const SizedBox(width: 8), + const SizedBox(width: 6), ], - const SizedBox(width: 4), - const _ToolbarDivider(), - const SizedBox(width: 10), - ], - for (final period in TopPeriodFilter.values) ...[ - ChoiceChip( - selected: widget.topPeriodFilter == period, - label: Text(period.label), - onSelected: (_) => widget.onTopPeriodChanged(period), - ), - const SizedBox(width: 8), ], ], ), @@ -289,64 +284,56 @@ class _FeedToolbarState extends State { primary: false, children: [ if (hasActiveFilters || widget.selectedTags.isNotEmpty) ...[ - ActionChip( - visualDensity: VisualDensity.compact, - avatar: const Icon(Icons.filter_alt_off_rounded, size: 15), - label: const Text('Сбросить'), - onPressed: widget.onClearFilters, + _LiquidProviderPill( + label: 'Сбросить', + isSelected: false, + icon: Icons.filter_alt_off_rounded, + onTap: widget.onClearFilters, ), const SizedBox(width: 6), ], - ActionChip( - visualDensity: VisualDensity.compact, - avatar: const Icon(Icons.refresh_rounded, size: 15), - label: const Text('Обновить'), - onPressed: widget.onRefresh, + _LiquidProviderPill( + label: 'Обновить', + isSelected: false, + icon: Icons.refresh_rounded, + onTap: widget.onRefresh, ), const SizedBox(width: 6), const _ToolbarDivider(), const SizedBox(width: 6), - FilterChip( - visualDensity: VisualDensity.compact, - selected: widget.rating != null, - avatar: const Icon(Icons.shield_outlined, size: 15), - label: Text(widget.rating ?? 'Рейтинг'), - onSelected: (_) => widget.onRatingFilter(), + _LiquidProviderPill( + label: widget.rating ?? 'Рейтинг', + isSelected: widget.rating != null, + icon: Icons.shield_outlined, + onTap: widget.onRatingFilter, ), const SizedBox(width: 6), const _ToolbarDivider(), const SizedBox(width: 6), + _LiquidPeriodTabs( + selectedPeriod: widget.topPeriodFilter, + onChanged: widget.onTopPeriodChanged, + ), if (widget.providers.isNotEmpty) ...[ - FilterChip( - visualDensity: VisualDensity.compact, - selected: widget.selectedProviderIds.isEmpty, - label: const Text('Все'), - onSelected: (_) => widget.onQuickProviderToggle('__all__'), + const SizedBox(width: 6), + const _ToolbarDivider(), + const SizedBox(width: 6), + _LiquidProviderPill( + label: 'Все', + isSelected: widget.selectedProviderIds.isEmpty, + icon: Icons.all_inclusive_rounded, + onTap: () => widget.onQuickProviderToggle('__all__'), ), const SizedBox(width: 6), for (final provider in widget.providers) ...[ - FilterChip( - visualDensity: VisualDensity.compact, - selected: - widget.selectedProviderIds.contains(provider.id), - label: Text(provider.name), - onSelected: (_) => - widget.onQuickProviderToggle(provider.id), + _LiquidProviderPill( + label: provider.name, + isSelected: widget.selectedProviderIds.contains(provider.id), + icon: Icons.hub_rounded, + onTap: () => widget.onQuickProviderToggle(provider.id), ), const SizedBox(width: 6), ], - const SizedBox(width: 2), - const _ToolbarDivider(), - const SizedBox(width: 6), - ], - for (final period in TopPeriodFilter.values) ...[ - ChoiceChip( - visualDensity: VisualDensity.compact, - selected: widget.topPeriodFilter == period, - label: Text(period.label), - onSelected: (_) => widget.onTopPeriodChanged(period), - ), - const SizedBox(width: 6), ], ], ), @@ -371,6 +358,227 @@ class _ToolbarDivider extends StatelessWidget { } } +class _LiquidPeriodTabs extends StatelessWidget { + const _LiquidPeriodTabs({ + required this.selectedPeriod, + required this.onChanged, + }); + + final TopPeriodFilter selectedPeriod; + final ValueChanged onChanged; + + IconData _iconFor(TopPeriodFilter period) { + return switch (period) { + TopPeriodFilter.none => Icons.auto_awesome_rounded, + TopPeriodFilter.month => Icons.calendar_month_rounded, + TopPeriodFilter.year => Icons.calendar_today_rounded, + TopPeriodFilter.allTime => Icons.workspace_premium_rounded, + }; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + + return Container( + padding: const EdgeInsets.all(2.5), + decoration: BoxDecoration( + color: isDark + ? scheme.surfaceContainerHigh.withValues(alpha: 0.55) + : scheme.surfaceContainerHighest.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.1) + : Colors.black.withValues(alpha: 0.06), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final period in TopPeriodFilter.values) ...[ + _LiquidPeriodTabItem( + period: period, + icon: _iconFor(period), + isSelected: selectedPeriod == period, + onTap: () => onChanged(period), + ), + if (period != TopPeriodFilter.values.last) const SizedBox(width: 3), + ], + ], + ), + ); + } +} + +class _LiquidPeriodTabItem extends StatelessWidget { + const _LiquidPeriodTabItem({ + required this.period, + required this.icon, + required this.isSelected, + required this.onTap, + }); + + final TopPeriodFilter period; + final IconData icon; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(13), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: isSelected + ? (isDark + ? scheme.primary.withValues(alpha: 0.28) + : scheme.primaryContainer.withValues(alpha: 0.85)) + : Colors.transparent, + borderRadius: BorderRadius.circular(13), + border: isSelected + ? Border.all( + color: scheme.primary.withValues(alpha: isDark ? 0.45 : 0.3), + width: 1, + ) + : null, + boxShadow: isSelected + ? [ + BoxShadow( + color: scheme.primary.withValues(alpha: isDark ? 0.25 : 0.12), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 14, + color: isSelected + ? (isDark ? scheme.primary : scheme.onPrimaryContainer) + : scheme.onSurfaceVariant.withValues(alpha: 0.85), + ), + const SizedBox(width: 6), + Text( + period.label, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected + ? (isDark ? scheme.primary : scheme.onPrimaryContainer) + : scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _LiquidProviderPill extends StatelessWidget { + const _LiquidProviderPill({ + required this.label, + required this.isSelected, + required this.onTap, + this.icon, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: isSelected + ? (isDark + ? scheme.secondaryContainer.withValues(alpha: 0.35) + : scheme.secondaryContainer.withValues(alpha: 0.7)) + : (isDark + ? scheme.surfaceContainerHigh.withValues(alpha: 0.4) + : scheme.surfaceContainerHighest.withValues(alpha: 0.35)), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isSelected + ? scheme.secondary.withValues(alpha: isDark ? 0.6 : 0.4) + : (isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.05)), + width: 1, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: scheme.secondary.withValues(alpha: isDark ? 0.2 : 0.08), + blurRadius: 6, + ), + ] + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 14, + color: isSelected + ? (isDark ? scheme.secondary : scheme.onSecondaryContainer) + : scheme.onSurfaceVariant.withValues(alpha: 0.8), + ), + const SizedBox(width: 5), + ], + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected + ? (isDark ? scheme.secondary : scheme.onSecondaryContainer) + : scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } +} + class _HorizontalWheelScroller extends StatefulWidget { const _HorizontalWheelScroller({ required this.builder, diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index 0aa8858..5b27f54 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -172,6 +172,8 @@ class PostDetailsScreen extends ConsumerWidget { LogicalKeySet(LogicalKeyboardKey.keyC): const _AddCollectionIntent(), LogicalKeySet(LogicalKeyboardKey.keyS): const _DownloadIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS): + const _DownloadIntent(), LogicalKeySet(LogicalKeyboardKey.escape): const _CloseIntent(), }, child: Actions( diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index 006ec45..6337047 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -383,16 +383,54 @@ class _PostMediaViewerState extends State ), ], ); + + final interactiveChild = _imageUrls.length > 1 + ? Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.arrowLeft): + const _PrevImageIntent(), + LogicalKeySet(LogicalKeyboardKey.keyA): const _PrevImageIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowRight): + const _NextImageIntent(), + LogicalKeySet(LogicalKeyboardKey.keyD): const _NextImageIntent(), + }, + child: Actions( + actions: { + _PrevImageIntent: CallbackAction<_PrevImageIntent>( + onInvoke: (_) { + if (_imageIndex > 0) { + setState(() => _imageIndex--); + } + return null; + }, + ), + _NextImageIntent: CallbackAction<_NextImageIntent>( + onInvoke: (_) { + if (_imageIndex < _imageUrls.length - 1) { + setState(() => _imageIndex++); + } + return null; + }, + ), + }, + child: Focus( + autofocus: true, + child: child, + ), + ), + ) + : child; + return LayoutBuilder( builder: (context, constraints) { if (constraints.hasBoundedWidth && constraints.hasBoundedHeight) { return SizedBox( width: constraints.maxWidth, height: constraints.maxHeight, - child: child, + child: interactiveChild, ); } - return child; + return interactiveChild; }, ); } @@ -739,6 +777,48 @@ class _TogglePlayIntent extends Intent { const _TogglePlayIntent(); } +class _SeekVideoIntent extends Intent { + const _SeekVideoIntent(this.seconds); + final int seconds; +} + +class _VolumeVideoIntent extends Intent { + const _VolumeVideoIntent(this.deltaY); + final double deltaY; +} + +class _MuteVideoIntent extends Intent { + const _MuteVideoIntent(); +} + +class _FullscreenVideoIntent extends Intent { + const _FullscreenVideoIntent(); +} + +class _LoopVideoIntent extends Intent { + const _LoopVideoIntent(); +} + +class _ZoomInIntent extends Intent { + const _ZoomInIntent(); +} + +class _ZoomOutIntent extends Intent { + const _ZoomOutIntent(); +} + +class _ResetZoomIntent extends Intent { + const _ResetZoomIntent(); +} + +class _PrevImageIntent extends Intent { + const _PrevImageIntent(); +} + +class _NextImageIntent extends Intent { + const _NextImageIntent(); +} + class _UnsupportedSwfPanel extends StatelessWidget { const _UnsupportedSwfPanel(); @@ -911,6 +991,8 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> { shortcuts: { LogicalKeySet(LogicalKeyboardKey.escape): const _CloseVideoIntent(), LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(), + LogicalKeySet(LogicalKeyboardKey.keyK): const _TogglePlayIntent(), + LogicalKeySet(LogicalKeyboardKey.keyF): const _CloseVideoIntent(), }, child: Actions( actions: { @@ -1771,16 +1853,87 @@ class _VideoSurfaceState extends State<_VideoSurface> { }, ); - return MouseRegion( - onHover: (_) => widget.onInteract(), - child: ClipRRect( - borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10), - child: widget.fullscreen - ? SizedBox.expand(child: child) - : AspectRatio( - aspectRatio: widget.aspectRatio.clamp(0.35, 2.4), - child: child, - ), + return Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(), + LogicalKeySet(LogicalKeyboardKey.keyK): const _TogglePlayIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowLeft): const _SeekVideoIntent(-5), + LogicalKeySet(LogicalKeyboardKey.keyJ): const _SeekVideoIntent(-5), + LogicalKeySet(LogicalKeyboardKey.arrowRight): const _SeekVideoIntent(5), + LogicalKeySet(LogicalKeyboardKey.keyL): const _SeekVideoIntent(5), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): + const _SeekVideoIntent(-15), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): + const _SeekVideoIntent(15), + LogicalKeySet(LogicalKeyboardKey.arrowUp): const _VolumeVideoIntent(-10), + LogicalKeySet(LogicalKeyboardKey.arrowDown): const _VolumeVideoIntent(10), + LogicalKeySet(LogicalKeyboardKey.keyM): const _MuteVideoIntent(), + LogicalKeySet(LogicalKeyboardKey.keyF): const _FullscreenVideoIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyL): + const _LoopVideoIntent(), + }, + child: Actions( + actions: { + _TogglePlayIntent: CallbackAction<_TogglePlayIntent>( + onInvoke: (_) { + if (widget.player.state.playing) { + widget.player.pause(); + } else { + if (widget.player.state.completed) { + widget.player.seek(Duration.zero); + } + widget.player.play(); + } + widget.onInteract(); + return null; + }, + ), + _SeekVideoIntent: CallbackAction<_SeekVideoIntent>( + onInvoke: (intent) { + _seekBy(Duration(seconds: intent.seconds)); + return null; + }, + ), + _VolumeVideoIntent: CallbackAction<_VolumeVideoIntent>( + onInvoke: (intent) { + _adjustVolume(intent.deltaY); + return null; + }, + ), + _MuteVideoIntent: CallbackAction<_MuteVideoIntent>( + onInvoke: (_) { + widget.onToggleMute(); + return null; + }, + ), + _FullscreenVideoIntent: CallbackAction<_FullscreenVideoIntent>( + onInvoke: (_) { + widget.onFullscreen(); + return null; + }, + ), + _LoopVideoIntent: CallbackAction<_LoopVideoIntent>( + onInvoke: (_) { + widget.onToggleLoop(); + return null; + }, + ), + }, + child: Focus( + autofocus: true, + child: MouseRegion( + onHover: (_) => widget.onInteract(), + child: ClipRRect( + borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10), + child: widget.fullscreen + ? SizedBox.expand(child: child) + : AspectRatio( + aspectRatio: widget.aspectRatio.clamp(0.35, 2.4), + child: child, + ), + ), + ), + ), ), ); } @@ -1959,80 +2112,147 @@ class _ZoomableImageState extends State<_ZoomableImage> { super.dispose(); } + void _zoomIn() { + final currentScale = _controller.value.getMaxScaleOnAxis(); + final newScale = (currentScale * 1.35).clamp(1.0, 6.0); + _controller.value = Matrix4.identity()..scaleByDouble(newScale, newScale, 1, 1); + if (mounted) setState(() => _zoomed = newScale > 1.02); + _setLocked(_zoomed); + } + + void _zoomOut() { + final currentScale = _controller.value.getMaxScaleOnAxis(); + final newScale = (currentScale / 1.35).clamp(1.0, 6.0); + if (newScale <= 1.02) { + _controller.value = Matrix4.identity(); + if (mounted) setState(() => _zoomed = false); + _setLocked(false); + } else { + _controller.value = Matrix4.identity()..scaleByDouble(newScale, newScale, 1, 1); + if (mounted) setState(() => _zoomed = true); + _setLocked(true); + } + } + + void _resetZoom() { + _controller.value = Matrix4.identity(); + if (mounted) setState(() => _zoomed = false); + _setLocked(false); + } + @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final hasFrame = - constraints.hasBoundedWidth && constraints.hasBoundedHeight; - final child = hasFrame - ? SizedBox( - width: constraints.maxWidth, - height: constraints.maxHeight, - child: widget.child, - ) - : widget.child; - return Listener( - onPointerSignal: (signal) { - if (signal is PointerScrollEvent) { - final scaleDelta = signal.scrollDelta.dy < 0 ? 1.15 : 0.85; - final currentScale = _controller.value.getMaxScaleOnAxis(); - final newScale = (currentScale * scaleDelta).clamp(1.0, 6.0); - if (newScale <= 1.02) { - _controller.value = Matrix4.identity(); - if (mounted) setState(() => _zoomed = false); - _setLocked(false); - } else { - final focalPoint = signal.localPosition; - _controller.value = Matrix4.identity() - ..translateByDouble( - -focalPoint.dx * (newScale - 1), - -focalPoint.dy * (newScale - 1), - 0, - 1) - ..scaleByDouble(newScale, newScale, 1, 1); - if (mounted) setState(() => _zoomed = true); - _setLocked(true); - } - } - }, - onPointerDown: (_) { - _pointerCount++; - if (_pointerCount >= 2 || _zoomed) { - _setLocked(true); - } - }, - onPointerUp: (_) => _releasePointer(), - onPointerCancel: (_) => _releasePointer(), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onDoubleTapDown: (details) => _doubleTapDetails = details, - onDoubleTap: _toggleZoom, - child: InteractiveViewer( - transformationController: _controller, - minScale: 1, - maxScale: 6, - boundaryMargin: const EdgeInsets.all(160), - panEnabled: _zoomed, - scaleEnabled: true, - clipBehavior: Clip.none, - onInteractionStart: (_) { - if (_pointerCount >= 2 || _zoomed) _setLocked(true); - }, - onInteractionEnd: (_) { - final scale = _controller.value.getMaxScaleOnAxis(); - final nextZoomed = scale > 1.03; - if (!nextZoomed) { - _controller.value = Matrix4.identity(); - } - if (mounted) setState(() => _zoomed = nextZoomed); - _setLocked(nextZoomed || _pointerCount >= 2); - }, - child: child, - ), - ), - ); + return Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.equal): const _ZoomInIntent(), + LogicalKeySet(LogicalKeyboardKey.add): const _ZoomInIntent(), + LogicalKeySet(LogicalKeyboardKey.numpadAdd): const _ZoomInIntent(), + LogicalKeySet(LogicalKeyboardKey.minus): const _ZoomOutIntent(), + LogicalKeySet(LogicalKeyboardKey.numpadSubtract): const _ZoomOutIntent(), + LogicalKeySet(LogicalKeyboardKey.digit0): const _ResetZoomIntent(), + LogicalKeySet(LogicalKeyboardKey.numpad0): const _ResetZoomIntent(), + LogicalKeySet(LogicalKeyboardKey.escape): const _ResetZoomIntent(), }, + child: Actions( + actions: { + _ZoomInIntent: CallbackAction<_ZoomInIntent>( + onInvoke: (_) { + _zoomIn(); + return null; + }, + ), + _ZoomOutIntent: CallbackAction<_ZoomOutIntent>( + onInvoke: (_) { + _zoomOut(); + return null; + }, + ), + _ResetZoomIntent: CallbackAction<_ResetZoomIntent>( + onInvoke: (_) { + if (_zoomed) { + _resetZoom(); + } + return null; + }, + ), + }, + child: Focus( + autofocus: true, + child: LayoutBuilder( + builder: (context, constraints) { + final hasFrame = + constraints.hasBoundedWidth && constraints.hasBoundedHeight; + final child = hasFrame + ? SizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight, + child: widget.child, + ) + : widget.child; + return Listener( + onPointerSignal: (signal) { + if (signal is PointerScrollEvent) { + final scaleDelta = signal.scrollDelta.dy < 0 ? 1.15 : 0.85; + final currentScale = _controller.value.getMaxScaleOnAxis(); + final newScale = (currentScale * scaleDelta).clamp(1.0, 6.0); + if (newScale <= 1.02) { + _controller.value = Matrix4.identity(); + if (mounted) setState(() => _zoomed = false); + _setLocked(false); + } else { + final focalPoint = signal.localPosition; + _controller.value = Matrix4.identity() + ..translateByDouble( + -focalPoint.dx * (newScale - 1), + -focalPoint.dy * (newScale - 1), + 0, + 1) + ..scaleByDouble(newScale, newScale, 1, 1); + if (mounted) setState(() => _zoomed = true); + _setLocked(true); + } + } + }, + onPointerDown: (_) { + _pointerCount++; + if (_pointerCount >= 2 || _zoomed) { + _setLocked(true); + } + }, + onPointerUp: (_) => _releasePointer(), + onPointerCancel: (_) => _releasePointer(), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onDoubleTapDown: (details) => _doubleTapDetails = details, + onDoubleTap: _toggleZoom, + child: InteractiveViewer( + transformationController: _controller, + minScale: 1, + maxScale: 6, + boundaryMargin: const EdgeInsets.all(160), + panEnabled: _zoomed, + scaleEnabled: true, + clipBehavior: Clip.none, + onInteractionStart: (_) { + if (_pointerCount >= 2 || _zoomed) _setLocked(true); + }, + onInteractionEnd: (_) { + final scale = _controller.value.getMaxScaleOnAxis(); + final nextZoomed = scale > 1.03; + if (!nextZoomed) { + _controller.value = Matrix4.identity(); + } + if (mounted) setState(() => _zoomed = nextZoomed); + _setLocked(nextZoomed || _pointerCount >= 2); + }, + child: child, + ), + ), + ); + }, + ), + ), + ), ); } diff --git a/lib/shared/widgets/app_shell.dart b/lib/shared/widgets/app_shell.dart index 81d0cac..72cdbae 100644 --- a/lib/shared/widgets/app_shell.dart +++ b/lib/shared/widgets/app_shell.dart @@ -10,6 +10,8 @@ import '../../app/responsive.dart'; import '../../app/router.dart'; import '../../backend/backend.dart'; import '../../core/utils/result.dart'; +import '../../features/feed/presentation/feed_controller.dart'; +import 'desktop_shortcuts_dialog.dart'; class AppShell extends ConsumerStatefulWidget { const AppShell({ @@ -249,21 +251,54 @@ class _AppShellState extends ConsumerState { LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF): const _NavigateIntent('/search'), LogicalKeySet(LogicalKeyboardKey.escape): const _BackIntent(), - LogicalKeySet(LogicalKeyboardKey.digit1): const _NavigateIntent('/'), - LogicalKeySet(LogicalKeyboardKey.digit2): + // Ctrl + 1..8 + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit1): + const _NavigateIntent('/'), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit2): const _NavigateIntent('/search'), - LogicalKeySet(LogicalKeyboardKey.digit3): + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit3): const _NavigateIntent('/favorites'), - LogicalKeySet(LogicalKeyboardKey.digit4): + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit4): const _NavigateIntent('/viewed'), - LogicalKeySet(LogicalKeyboardKey.digit5): + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit5): const _NavigateIntent('/collections'), - LogicalKeySet(LogicalKeyboardKey.digit6): + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit6): const _NavigateIntent('/artists'), - LogicalKeySet(LogicalKeyboardKey.digit7): + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit7): const _NavigateIntent('/providers'), - LogicalKeySet(LogicalKeyboardKey.digit8): + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit8): const _NavigateIntent('/settings'), + // Alt + 1..8 + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.digit1): + const _NavigateIntent('/'), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.digit2): + const _NavigateIntent('/search'), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.digit3): + const _NavigateIntent('/favorites'), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.digit4): + const _NavigateIntent('/viewed'), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.digit5): + const _NavigateIntent('/collections'), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.digit6): + const _NavigateIntent('/artists'), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.digit7): + const _NavigateIntent('/providers'), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.digit8): + const _NavigateIntent('/settings'), + // Tab cycling + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.tab): + const _CycleTabIntent(forward: true), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): + const _CycleTabIntent(forward: false), + // Refresh + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyR): + const _RefreshIntent(), + LogicalKeySet(LogicalKeyboardKey.f5): const _RefreshIntent(), + // Shortcuts help cheat sheet + LogicalKeySet(LogicalKeyboardKey.question): const _HelpIntent(), + LogicalKeySet(LogicalKeyboardKey.f1): const _HelpIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.slash): + const _HelpIntent(), }, child: Actions( actions: { @@ -279,6 +314,33 @@ class _AppShellState extends ConsumerState { return null; }, ), + _CycleTabIntent: CallbackAction<_CycleTabIntent>( + onInvoke: (intent) { + if (destinations.isEmpty) return null; + final currentBranch = widget.navigationShell?.currentIndex ?? 0; + final currentIdx = destinations.indexWhere( + (d) => AppShell.branchIndexForLocation(d.location) == currentBranch, + ); + final safeIdx = currentIdx < 0 ? 0 : currentIdx; + final nextIdx = intent.forward + ? ((safeIdx + 1) % destinations.length) + : ((safeIdx - 1 + destinations.length) % destinations.length); + _onNavigate(destinations[nextIdx].location); + return null; + }, + ), + _RefreshIntent: CallbackAction<_RefreshIntent>( + onInvoke: (_) { + ref.read(feedControllerProvider.notifier).refresh(); + return null; + }, + ), + _HelpIntent: CallbackAction<_HelpIntent>( + onInvoke: (_) { + DesktopShortcutsDialog.show(context); + return null; + }, + ), }, child: Focus( autofocus: true, @@ -312,6 +374,7 @@ class _DesktopShell extends StatefulWidget { required this.onNavigate, this.currentBranchIndex, }); + final Widget child; final List<_Destination> destinations; final bool ru; @@ -324,15 +387,21 @@ class _DesktopShell extends StatefulWidget { class _DesktopShellState extends State<_DesktopShell> { bool _hovered = false; + bool _pinned = false; @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; final location = GoRouterState.of(context).uri.path; final activeBranch = widget.currentBranchIndex ?? AppShell.branchIndexForLocation(location); final selected = widget.destinations.indexWhere( (item) => AppShell.branchIndexForLocation(item.location) == activeBranch, ); + final isExpanded = _pinned || _hovered; + return Scaffold( body: Row( children: [ @@ -340,36 +409,249 @@ class _DesktopShellState extends State<_DesktopShell> { onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: AnimatedContainer( - duration: AppMotion.duration(context, 180), + duration: AppMotion.duration(context, 200), curve: Curves.easeOutCubic, - width: _hovered ? 256 : 72, - color: Theme.of(context).navigationRailTheme.backgroundColor, - child: SafeArea( - child: Padding( - padding: - const EdgeInsets.symmetric(vertical: 12, horizontal: 8), - child: Column( - children: [ - for (var index = 0; - index < widget.destinations.length; - index++) - _RailButton( - destination: widget.destinations[index], - ru: widget.ru, - selected: (selected < 0 ? 0 : selected) == index, - expanded: _hovered, - onTap: () { - widget.onNavigate(widget.destinations[index].location); - }, + width: isExpanded ? 260 : 74, + decoration: BoxDecoration( + color: isDark + ? scheme.surfaceContainerHigh.withValues(alpha: 0.75) + : scheme.surface.withValues(alpha: 0.85), + border: Border( + right: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.12) + : scheme.outlineVariant.withValues(alpha: 0.35), + width: 1, + ), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.3 : 0.05), + blurRadius: 18, + offset: const Offset(4, 0), + ), + ], + ), + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: SafeArea( + child: Column( + children: [ + // App Brand Header + Padding( + padding: const EdgeInsets.fromLTRB(14, 16, 12, 14), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + scheme.primary, + scheme.tertiary, + ], + ), + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: scheme.primary.withValues(alpha: 0.35), + blurRadius: 12, + offset: const Offset(0, 3), + ), + ], + ), + child: const Center( + child: Icon( + Icons.auto_awesome_rounded, + color: Colors.white, + size: 24, + ), + ), + ), + if (isExpanded) ...[ + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Lunaris', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 1.5, + ), + decoration: BoxDecoration( + color: scheme.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: scheme.primary.withValues(alpha: 0.3), + width: 0.8, + ), + ), + child: Text( + 'v3.6.0', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: scheme.primary, + letterSpacing: 0.4, + ), + ), + ), + ], + ), + ), + IconButton( + visualDensity: VisualDensity.compact, + tooltip: _pinned + ? (widget.ru ? 'Открепить панель' : 'Unpin panel') + : (widget.ru ? 'Закрепить панель' : 'Pin panel'), + onPressed: () { + setState(() => _pinned = !_pinned); + }, + icon: Icon( + _pinned + ? Icons.push_pin_rounded + : Icons.push_pin_outlined, + size: 19, + color: _pinned ? scheme.primary : scheme.onSurfaceVariant, + ), + ), + ], + ], + ), ), - const Spacer(), - ], + const Divider(height: 1), + const SizedBox(height: 8), + + // Destinations List + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 8), + children: [ + for (var index = 0; + index < widget.destinations.length; + index++) + _RailButton( + destination: widget.destinations[index], + ru: widget.ru, + selected: (selected < 0 ? 0 : selected) == index, + expanded: isExpanded, + shortcutKey: 'Ctrl+${index + 1}', + onTap: () { + widget.onNavigate( + widget.destinations[index].location); + }, + ), + ], + ), + ), + + // Bottom Actions + const Divider(height: 1), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 8), + child: Column( + children: [ + // Shortcuts helper button + Tooltip( + message: isExpanded + ? '' + : (widget.ru + ? 'Горячие клавиши (F1 / ?)' + : 'Keyboard Shortcuts (F1 / ?)'), + waitDuration: const Duration(milliseconds: 350), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => DesktopShortcutsDialog.show(context), + child: SizedBox( + height: 44, + child: Row( + children: [ + SizedBox( + width: 58, + child: Icon( + Icons.keyboard_rounded, + size: 22, + color: scheme.onSurfaceVariant, + ), + ), + if (isExpanded) ...[ + Expanded( + child: Text( + widget.ru + ? 'Горячие клавиши' + : 'Shortcuts', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: scheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.1) + : Colors.black.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + '?', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + fontFamily: 'monospace', + ), + ), + ), + const SizedBox(width: 8), + ], + ], + ), + ), + ), + ), + ), + + // Quick pin toggle button when collapsed + if (!isExpanded) + IconButton( + tooltip: widget.ru ? 'Закрепить панель' : 'Pin sidebar', + onPressed: () => setState(() => _pinned = true), + icon: const Icon(Icons.chevron_right_rounded, size: 20), + ), + ], + ), + ), + ], + ), ), ), ), ), ), - const VerticalDivider(width: 1), Expanded(child: widget.child), ], ), @@ -383,6 +665,7 @@ class _RailButton extends StatelessWidget { required this.selected, required this.expanded, required this.ru, + required this.shortcutKey, required this.onTap, }); @@ -390,65 +673,117 @@ class _RailButton extends StatelessWidget { final bool selected; final bool expanded; final bool ru; + final String shortcutKey; final VoidCallback onTap; @override Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + final label = destination.labelFor(ru); + return Padding( - padding: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(vertical: 2), child: Tooltip( - message: expanded ? '' : destination.labelFor(ru), - waitDuration: const Duration(milliseconds: 450), + message: expanded ? '' : '$label ($shortcutKey)', + waitDuration: const Duration(milliseconds: 350), child: Material( - color: selected ? colors.primaryContainer : Colors.transparent, - borderRadius: BorderRadius.circular(28), + color: selected + ? scheme.primary.withValues(alpha: isDark ? 0.22 : 0.12) + : Colors.transparent, + borderRadius: BorderRadius.circular(14), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, - child: SizedBox( - height: 48, + child: Container( + height: 46, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: selected + ? Border.all( + color: scheme.primary.withValues(alpha: isDark ? 0.45 : 0.28), + width: 1.1, + ) + : null, + ), child: Row( children: [ - SizedBox( - width: 56, - child: Icon( - destination.icon, - color: selected - ? colors.onPrimaryContainer - : colors.onSurfaceVariant, + // Active left indicator bar + Container( + width: 3.5, + height: 22, + decoration: BoxDecoration( + color: selected ? scheme.primary : Colors.transparent, + borderRadius: BorderRadius.circular(2), + boxShadow: selected + ? [ + BoxShadow( + color: scheme.primary.withValues(alpha: 0.6), + blurRadius: 6, + ), + ] + : null, ), ), - if (expanded) + SizedBox( + width: 54.5, + child: Center( + child: Icon( + destination.icon, + size: 22, + color: selected + ? scheme.primary + : (isDark + ? scheme.onSurfaceVariant.withValues(alpha: 0.85) + : scheme.onSurfaceVariant), + ), + ), + ), + if (expanded) ...[ Expanded( - child: ClipRect( - child: AnimatedOpacity( - opacity: 1, - duration: AppMotion.duration(context, 120), - curve: Curves.easeOut, - child: Align( - alignment: Alignment.centerLeft, - child: Text( - destination.labelFor(ru), - maxLines: 1, - overflow: TextOverflow.clip, - softWrap: false, - style: TextStyle( - fontWeight: selected - ? FontWeight.w700 - : FontWeight.w600, - color: selected - ? colors.onPrimaryContainer - : colors.onSurface, - ), - ), - ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.5, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + color: selected ? scheme.primary : scheme.onSurface, ), ), - ) - else - const SizedBox.shrink(), - if (expanded) const SizedBox(width: 12), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 5.5, + vertical: 2, + ), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.12) + : Colors.black.withValues(alpha: 0.08), + width: 0.8, + ), + ), + child: Text( + shortcutKey, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + fontFamily: 'monospace', + color: selected + ? scheme.primary + : scheme.onSurfaceVariant.withValues(alpha: 0.8), + ), + ), + ), + const SizedBox(width: 10), + ], ], ), ), @@ -945,3 +1280,16 @@ class _NavigateIntent extends Intent { class _BackIntent extends Intent { const _BackIntent(); } + +class _CycleTabIntent extends Intent { + const _CycleTabIntent({required this.forward}); + final bool forward; +} + +class _RefreshIntent extends Intent { + const _RefreshIntent(); +} + +class _HelpIntent extends Intent { + const _HelpIntent(); +} diff --git a/lib/shared/widgets/desktop_shortcuts_dialog.dart b/lib/shared/widgets/desktop_shortcuts_dialog.dart new file mode 100644 index 0000000..daeed5a --- /dev/null +++ b/lib/shared/widgets/desktop_shortcuts_dialog.dart @@ -0,0 +1,321 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; + +class DesktopShortcutsDialog extends StatelessWidget { + const DesktopShortcutsDialog({super.key}); + + static Future show(BuildContext context) { + return showDialog( + context: context, + barrierColor: Colors.black54, + builder: (_) => const DesktopShortcutsDialog(), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720, maxHeight: 680), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + decoration: BoxDecoration( + color: isDark + ? scheme.surfaceContainerHigh.withValues(alpha: 0.88) + : Colors.white.withValues(alpha: 0.94), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.16) + : scheme.outlineVariant.withValues(alpha: 0.4), + width: 1.2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.45 : 0.12), + blurRadius: 32, + offset: const Offset(0, 12), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 16, 16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: scheme.primaryContainer.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + Icons.keyboard_rounded, + color: scheme.primary, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Горячие клавиши', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + 'Быстрое управление с клавиатуры для ПК', + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close_rounded), + tooltip: 'Закрыть (Esc)', + ), + ], + ), + ), + const Divider(height: 1), + + // Shortcuts list + Flexible( + child: ListView( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 24), + children: const [ + _ShortcutSection( + icon: Icons.navigation_rounded, + title: 'Общие и навигация', + items: [ + _ShortcutRow(['Ctrl', '1…8'], 'Переключение между вкладками'), + _ShortcutRow(['Ctrl', 'Tab'], 'Следующая вкладка'), + _ShortcutRow(['Ctrl', 'Shift', 'Tab'], 'Предыдущая вкладка'), + _ShortcutRow(['Ctrl', 'F'], 'Перейти к поиску / строка поиска'), + _ShortcutRow(['Ctrl', 'R'], 'Обновить ленту / страницу'), + _ShortcutRow(['F5'], 'Обновить страницу'), + _ShortcutRow(['Esc'], 'Закрыть окно / назад / сбросить выбор'), + _ShortcutRow(['?'], 'Открыть это окно горячих клавиш'), + ], + ), + SizedBox(height: 20), + _ShortcutSection( + icon: Icons.smart_display_rounded, + title: 'Видеоплеер', + items: [ + _ShortcutRow(['Пробел'], 'Воспроизведение / Пауза'), + _ShortcutRow(['←', '→'], 'Перемотка на 5 секунд назад / вперед'), + _ShortcutRow(['Shift', '← / →'], 'Перемотка на 15 секунд'), + _ShortcutRow(['↑', '↓'], 'Регулировка громкости (±5%)'), + _ShortcutRow(['M'], 'Включить / выключить звук'), + _ShortcutRow(['F'], 'Полноэкранный режим (Full Screen)'), + _ShortcutRow(['L'], 'Повтор видео (Loop on/off)'), + _ShortcutRow(['Esc'], 'Выйти из полноэкранного режима'), + ], + ), + SizedBox(height: 20), + _ShortcutSection( + icon: Icons.photo_library_rounded, + title: 'Фото и просмотр постов', + items: [ + _ShortcutRow(['←', '→'], 'Предыдущее / следующее фото или пост'), + _ShortcutRow(['+'], 'Увеличить масштаб (Zoom In)'), + _ShortcutRow(['-'], 'Уменьшить масштаб (Zoom Out)'), + _ShortcutRow(['0'], 'Сбросить масштаб (100%)'), + _ShortcutRow(['F'], 'Подгонка по экрану / на весь экран'), + _ShortcutRow(['D'], 'Скачать текущее изображение'), + _ShortcutRow(['Ctrl', 'S'], 'Сохранить / Скачать файл'), + _ShortcutRow(['Esc'], 'Сброс зума / назад'), + ], + ), + SizedBox(height: 20), + _ShortcutSection( + icon: Icons.grid_view_rounded, + title: 'Лента и посты', + items: [ + _ShortcutRow(['R'], 'Открыть случайный пост'), + _ShortcutRow(['V'], 'Включить/выключить режим выбора постов'), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _ShortcutSection extends StatelessWidget { + const _ShortcutSection({ + required this.icon, + required this.title, + required this.items, + }); + + final IconData icon; + final String title; + final List<_ShortcutRow> items; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 18, color: scheme.primary), + const SizedBox(width: 8), + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: scheme.primary, + letterSpacing: 0.3, + ), + ), + ], + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: isDark + ? scheme.surface.withValues(alpha: 0.6) + : scheme.surfaceContainerHighest.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.04), + ), + ), + child: Column( + children: [ + for (var i = 0; i < items.length; i++) ...[ + if (i > 0) + Divider( + height: 1, + thickness: 1, + color: isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.04), + ), + items[i], + ], + ], + ), + ), + ], + ); + } +} + +class _ShortcutRow extends StatelessWidget { + const _ShortcutRow(this.keys, this.description); + + final List keys; + final String description; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + child: Row( + children: [ + Expanded( + child: Text( + description, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 12), + Wrap( + spacing: 4, + runSpacing: 4, + children: keys.map((key) => _KeyCap(key)).toList(growable: false), + ), + ], + ), + ); + } +} + +class _KeyCap extends StatelessWidget { + const _KeyCap(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final scheme = theme.colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isDark + ? scheme.surfaceContainerHighest.withValues(alpha: 0.9) + : Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.2) + : Colors.black.withValues(alpha: 0.15), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.4 : 0.08), + offset: const Offset(0, 1.5), + blurRadius: 1, + ), + ], + ), + child: Text( + text, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + fontWeight: FontWeight.bold, + color: scheme.onSurface, + letterSpacing: 0.2, + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index c4c46ea..437dc9f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: gel_rule_app description: Local backend/core layer for a Flutter booru browser app. publish_to: "none" -version: 3.5.5+81 +version: 3.6.0+82 environment: sdk: ">=3.3.0 <4.0.0"