From 2cb00c451e875cf49afb03ed250329d990c043b4 Mon Sep 17 00:00:00 2001 From: "Dr.Blank" <64108942+Dr-Blank@users.noreply.github.com> Date: Wed, 23 Apr 2025 00:22:32 +0530 Subject: [PATCH 1/2] feat: implement scroll-to-top FAB and enhance library item app bar with scroll listener --- .../item_viewer/view/library_item_page.dart | 70 ++++++++++++++++++- .../view/library_item_sliver_app_bar.dart | 68 ++++++++++++++++-- 2 files changed, 130 insertions(+), 8 deletions(-) diff --git a/lib/features/item_viewer/view/library_item_page.dart b/lib/features/item_viewer/view/library_item_page.dart index 53db17e..781e2d0 100644 --- a/lib/features/item_viewer/view/library_item_page.dart +++ b/lib/features/item_viewer/view/library_item_page.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:animated_theme_switcher/animated_theme_switcher.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:vaani/api/library_item_provider.dart'; import 'package:vaani/features/item_viewer/view/library_item_sliver_app_bar.dart'; @@ -23,19 +24,86 @@ class LibraryItemPage extends HookConsumerWidget { final String itemId; final Object? extra; + static const double _showFabThreshold = 300.0; @override Widget build(BuildContext context, WidgetRef ref) { final additionalItemData = extra is LibraryItemExtras ? extra as LibraryItemExtras : null; + final scrollController = useScrollController(); + final showFab = useState(false); + + // Effect to listen to scroll changes and update FAB visibility + useEffect(() { + void listener() { + if (!scrollController.hasClients) { + return; // Ensure controller is attached + } + final shouldShow = scrollController.offset > _showFabThreshold; + // Update state only if it changes and widget is still mounted + if (showFab.value != shouldShow && context.mounted) { + showFab.value = shouldShow; + } + } + + scrollController.addListener(listener); + // Initial check in case the view starts scrolled (less likely but safe) + WidgetsBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients && context.mounted) { + listener(); + } + }); + + // Cleanup: remove the listener when the widget is disposed + return () => scrollController.removeListener(listener); + }, [scrollController],); // Re-run effect if scrollController changes + + // --- FAB Scroll-to-Top Logic --- + void scrollToTop() { + if (scrollController.hasClients) { + scrollController.animateTo( + 0.0, // Target offset (top) + duration: 300.ms, + curve: Curves.easeInOut, + ); + } + } return ThemeProvider( initTheme: Theme.of(context), duration: 200.ms, child: ThemeSwitchingArea( child: Scaffold( + floatingActionButton: AnimatedSwitcher( + duration: 250.ms, + // A common transition for FABs (fade + scale) + transitionBuilder: (Widget child, Animation animation) { + return ScaleTransition( + scale: animation, + child: FadeTransition( + opacity: animation, + child: child, + ), + ); + }, + child: showFab.value + ? FloatingActionButton( + // Key is important for AnimatedSwitcher to differentiate + key: const ValueKey('fab-scroll-top'), + onPressed: scrollToTop, + tooltip: 'Scroll to top', + child: const Icon(Icons.arrow_upward), + ) + : const SizedBox.shrink( + key: ValueKey('fab-empty'), + ), + ), body: CustomScrollView( + controller: scrollController, slivers: [ - const LibraryItemSliverAppBar(), + LibraryItemSliverAppBar( + id: itemId, + scrollController: scrollController, + ), SliverPadding( padding: const EdgeInsets.all(8), sliver: LibraryItemHeroSection( diff --git a/lib/features/item_viewer/view/library_item_sliver_app_bar.dart b/lib/features/item_viewer/view/library_item_sliver_app_bar.dart index ef133ee..7fb1e83 100644 --- a/lib/features/item_viewer/view/library_item_sliver_app_bar.dart +++ b/lib/features/item_viewer/view/library_item_sliver_app_bar.dart @@ -1,22 +1,76 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:vaani/api/library_item_provider.dart' show libraryItemProvider; -class LibraryItemSliverAppBar extends StatelessWidget { +class LibraryItemSliverAppBar extends HookConsumerWidget { const LibraryItemSliverAppBar({ super.key, + required this.id, + required this.scrollController, }); + final String id; + final ScrollController scrollController; + + static const double _showTitleThreshold = kToolbarHeight * 0.5; + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final item = ref.watch(libraryItemProvider(id)).valueOrNull; + + final showTitle = useState(false); + + useEffect(() { + void listener() { + final shouldShow = scrollController.hasClients && + scrollController.offset > _showTitleThreshold; + if (showTitle.value != shouldShow) { + showTitle.value = shouldShow; + } + } + + scrollController.addListener(listener); + // Trigger listener once initially in case the view starts scrolled + // (though unlikely for this specific use case, it's good practice) + WidgetsBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients) { + listener(); + } + }); + return () => scrollController.removeListener(listener); + }, [scrollController],); + return SliverAppBar( elevation: 0, - floating: true, + floating: false, + pinned: true, primary: true, - snap: true, actions: [ - // cast button - IconButton(onPressed: () {}, icon: const Icon(Icons.cast)), - IconButton(onPressed: () {}, icon: const Icon(Icons.more_vert)), + // IconButton( + // icon: const Icon(Icons.cast), + // onPressed: () { + // // Handle search action + // }, + // ), ], + title: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + child: showTitle.value + ? Text( + // Use a Key to help AnimatedSwitcher differentiate widgets + key: const ValueKey('title-text'), + item?.media.metadata.title ?? '', + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium,) + : const SizedBox( + // Also give it a key for differentiation + key: ValueKey('empty-title'), + width: 0, // Ensure it takes no space if possible + height: 0, + ), + ), + centerTitle: false, ); } } From ad0cd6e2ad5c939e8b037e1fc44288206e79300e Mon Sep 17 00:00:00 2001 From: "Dr.Blank" <64108942+Dr-Blank@users.noreply.github.com> Date: Wed, 23 Apr 2025 00:29:02 +0530 Subject: [PATCH 2/2] fix: run dart fix --- lib/api/library_provider.dart | 2 +- lib/api/library_provider.g.dart | 2 +- .../item_viewer/view/library_item_page.dart | 43 ++++++++++--------- .../view/library_item_sliver_app_bar.dart | 40 +++++++++-------- .../you/view/widgets/library_switch_chip.dart | 3 +- 5 files changed, 49 insertions(+), 41 deletions(-) diff --git a/lib/api/library_provider.dart b/lib/api/library_provider.dart index 0903015..62c79d8 100644 --- a/lib/api/library_provider.dart +++ b/lib/api/library_provider.dart @@ -26,7 +26,7 @@ Future library(Ref ref, String id) async { _logger.warning('No library found in the list of libraries'); return null; } - _logger.fine('Fetched library: ${library}'); + _logger.fine('Fetched library: $library'); return library.library; } diff --git a/lib/api/library_provider.g.dart b/lib/api/library_provider.g.dart index 8f22251..a8bc88a 100644 --- a/lib/api/library_provider.g.dart +++ b/lib/api/library_provider.g.dart @@ -6,7 +6,7 @@ part of 'library_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$libraryHash() => r'b62d976f8ab83b2f5823a0fb7dac52fde8fcbffc'; +String _$libraryHash() => r'f8a34100acb58f02fa958c71a629577bf815710e'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/features/item_viewer/view/library_item_page.dart b/lib/features/item_viewer/view/library_item_page.dart index 781e2d0..e7b9310 100644 --- a/lib/features/item_viewer/view/library_item_page.dart +++ b/lib/features/item_viewer/view/library_item_page.dart @@ -33,29 +33,32 @@ class LibraryItemPage extends HookConsumerWidget { final showFab = useState(false); // Effect to listen to scroll changes and update FAB visibility - useEffect(() { - void listener() { - if (!scrollController.hasClients) { - return; // Ensure controller is attached + useEffect( + () { + void listener() { + if (!scrollController.hasClients) { + return; // Ensure controller is attached + } + final shouldShow = scrollController.offset > _showFabThreshold; + // Update state only if it changes and widget is still mounted + if (showFab.value != shouldShow && context.mounted) { + showFab.value = shouldShow; + } } - final shouldShow = scrollController.offset > _showFabThreshold; - // Update state only if it changes and widget is still mounted - if (showFab.value != shouldShow && context.mounted) { - showFab.value = shouldShow; - } - } - scrollController.addListener(listener); - // Initial check in case the view starts scrolled (less likely but safe) - WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients && context.mounted) { - listener(); - } - }); + scrollController.addListener(listener); + // Initial check in case the view starts scrolled (less likely but safe) + WidgetsBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients && context.mounted) { + listener(); + } + }); - // Cleanup: remove the listener when the widget is disposed - return () => scrollController.removeListener(listener); - }, [scrollController],); // Re-run effect if scrollController changes + // Cleanup: remove the listener when the widget is disposed + return () => scrollController.removeListener(listener); + }, + [scrollController], + ); // Re-run effect if scrollController changes // --- FAB Scroll-to-Top Logic --- void scrollToTop() { diff --git a/lib/features/item_viewer/view/library_item_sliver_app_bar.dart b/lib/features/item_viewer/view/library_item_sliver_app_bar.dart index 7fb1e83..fd6b621 100644 --- a/lib/features/item_viewer/view/library_item_sliver_app_bar.dart +++ b/lib/features/item_viewer/view/library_item_sliver_app_bar.dart @@ -21,25 +21,28 @@ class LibraryItemSliverAppBar extends HookConsumerWidget { final showTitle = useState(false); - useEffect(() { - void listener() { - final shouldShow = scrollController.hasClients && - scrollController.offset > _showTitleThreshold; - if (showTitle.value != shouldShow) { - showTitle.value = shouldShow; + useEffect( + () { + void listener() { + final shouldShow = scrollController.hasClients && + scrollController.offset > _showTitleThreshold; + if (showTitle.value != shouldShow) { + showTitle.value = shouldShow; + } } - } - scrollController.addListener(listener); - // Trigger listener once initially in case the view starts scrolled - // (though unlikely for this specific use case, it's good practice) - WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) { - listener(); - } - }); - return () => scrollController.removeListener(listener); - }, [scrollController],); + scrollController.addListener(listener); + // Trigger listener once initially in case the view starts scrolled + // (though unlikely for this specific use case, it's good practice) + WidgetsBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients) { + listener(); + } + }); + return () => scrollController.removeListener(listener); + }, + [scrollController], + ); return SliverAppBar( elevation: 0, @@ -62,7 +65,8 @@ class LibraryItemSliverAppBar extends HookConsumerWidget { key: const ValueKey('title-text'), item?.media.metadata.title ?? '', overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium,) + style: Theme.of(context).textTheme.bodyMedium, + ) : const SizedBox( // Also give it a key for differentiation key: ValueKey('empty-title'), diff --git a/lib/features/you/view/widgets/library_switch_chip.dart b/lib/features/you/view/widgets/library_switch_chip.dart index 1463b8f..a673332 100644 --- a/lib/features/you/view/widgets/library_switch_chip.dart +++ b/lib/features/you/view/widgets/library_switch_chip.dart @@ -204,7 +204,8 @@ class _LibrarySelectionContent extends ConsumerWidget { trailing: isSelected ? const Icon(Icons.check) : null, onTap: () { appLogger.info( - 'Selected library: ${library.name} (ID: ${library.id})'); + 'Selected library: ${library.name} (ID: ${library.id})', + ); // Get current settings state final currentSettings = ref.read(apiSettingsProvider); // Update the active library ID