From 25ff8f1d1fc8ed16be70537fc8bfc025a2a30b20 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 4 Nov 2025 02:07:05 +0100 Subject: [PATCH] feat: horizontal scroll arrows on desktop --- lib/screens/discover_screen.dart | 38 ++-- .../horizontal_scroll_with_arrows.dart | 194 ++++++++++++++++++ 2 files changed, 215 insertions(+), 17 deletions(-) create mode 100644 lib/widgets/horizontal_scroll_with_arrows.dart diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 94b042c5..31c15576 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -10,6 +10,7 @@ import '../services/plex_auth_service.dart'; import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/user_avatar_widget.dart'; +import '../widgets/horizontal_scroll_with_arrows.dart'; import 'profile_switch_screen.dart'; import 'server_selection_screen.dart'; import '../providers/user_profile_provider.dart'; @@ -975,23 +976,26 @@ class _DiscoverScreenState extends State return SizedBox( height: containerHeight, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12), - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: MediaCard( - key: Key(item.ratingKey), - item: item, - width: cardWidth, - height: cardHeight, - onRefresh: updateItem, - ), - ); - }, + child: HorizontalScrollWithArrows( + builder: (scrollController) => ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: MediaCard( + key: Key(item.ratingKey), + item: item, + width: cardWidth, + height: cardHeight, + onRefresh: updateItem, + ), + ); + }, + ), ), ); }, diff --git a/lib/widgets/horizontal_scroll_with_arrows.dart b/lib/widgets/horizontal_scroll_with_arrows.dart new file mode 100644 index 00000000..8a01d067 --- /dev/null +++ b/lib/widgets/horizontal_scroll_with_arrows.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import '../utils/platform_detector.dart'; + +/// A wrapper widget that adds hover-activated navigation arrows to horizontal scrolling content. +/// The arrows only appear on desktop/web platforms and hide at scroll boundaries. +/// +/// This widget creates and manages its own ScrollController internally. Use the [builder] +/// constructor to access the ScrollController for the scrollable child widget. +class HorizontalScrollWithArrows extends StatefulWidget { + final Widget Function(ScrollController) builder; + final double scrollAmount; + + const HorizontalScrollWithArrows({ + super.key, + required this.builder, + this.scrollAmount = 0.8, // Scroll by 80% of viewport width by default + }); + + @override + State createState() => + _HorizontalScrollWithArrowsState(); +} + +class _HorizontalScrollWithArrowsState + extends State { + late final ScrollController _scrollController; + bool _isHovering = false; + bool _canScrollLeft = false; + bool _canScrollRight = false; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + _scrollController.addListener(_updateScrollState); + // Initial state update after first frame + WidgetsBinding.instance.addPostFrameCallback((_) => _updateScrollState()); + } + + @override + void dispose() { + _scrollController.removeListener(_updateScrollState); + _scrollController.dispose(); + super.dispose(); + } + + void _updateScrollState() { + if (!mounted) return; + + final position = _scrollController.position; + setState(() { + _canScrollLeft = position.pixels > 0; + _canScrollRight = position.pixels < position.maxScrollExtent; + }); + } + + void _scrollLeft() { + final position = _scrollController.position; + final targetScroll = (position.pixels - + (position.viewportDimension * widget.scrollAmount)) + .clamp(0.0, position.maxScrollExtent); + + _scrollController.animateTo( + targetScroll, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + void _scrollRight() { + final position = _scrollController.position; + final targetScroll = (position.pixels + + (position.viewportDimension * widget.scrollAmount)) + .clamp(0.0, position.maxScrollExtent); + + _scrollController.animateTo( + targetScroll, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + @override + Widget build(BuildContext context) { + final child = widget.builder(_scrollController); + + if (!PlatformDetector.isDesktop(context)) { + // On mobile, just return the child without arrows + return child; + } + + return MouseRegion( + onEnter: (_) => setState(() => _isHovering = true), + onExit: (_) => setState(() => _isHovering = false), + child: Stack( + children: [ + child, + // Left arrow + Positioned( + left: 8, + top: 0, + bottom: 0, + child: Center( + child: AnimatedOpacity( + opacity: (_isHovering && _canScrollLeft) ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: IgnorePointer( + ignoring: !(_isHovering && _canScrollLeft), + child: _NavigationArrow( + icon: Icons.chevron_left, + onPressed: _scrollLeft, + ), + ), + ), + ), + ), + // Right arrow + Positioned( + right: 8, + top: 0, + bottom: 0, + child: Center( + child: AnimatedOpacity( + opacity: (_isHovering && _canScrollRight) ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: IgnorePointer( + ignoring: !(_isHovering && _canScrollRight), + child: _NavigationArrow( + icon: Icons.chevron_right, + onPressed: _scrollRight, + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _NavigationArrow extends StatefulWidget { + final IconData icon; + final VoidCallback onPressed; + + const _NavigationArrow({ + required this.icon, + required this.onPressed, + }); + + @override + State<_NavigationArrow> createState() => _NavigationArrowState(); +} + +class _NavigationArrowState extends State<_NavigationArrow> { + bool _isPressed = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTapDown: (_) => setState(() => _isPressed = true), + onTapUp: (_) { + setState(() => _isPressed = false); + widget.onPressed(); + }, + onTapCancel: () => setState(() => _isPressed = false), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.black.withValues(alpha: _isPressed ? 0.9 : 0.7), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 8, + spreadRadius: 0, + ), + ], + ), + child: Icon( + widget.icon, + color: Colors.white, + size: 32, + ), + ), + ), + ); + } +}