From d9309015c51c1f5503d2a541fcff4612bcc5b5b2 Mon Sep 17 00:00:00 2001 From: tamslo Date: Tue, 25 Feb 2025 13:36:47 +0100 Subject: [PATCH] feat(app): use scroll indicator for tutorial --- app/lib/common/theme.dart | 2 +- .../scrollable_stack_with_indicator.dart | 146 +++++++++++++ .../widgets/tutorial/tutorial_builder.dart | 136 +++++------- .../widgets/tutorial/tutorial_controller.dart | 1 + app/lib/onboarding/pages/onboarding.dart | 201 ++++-------------- 5 files changed, 236 insertions(+), 250 deletions(-) create mode 100644 app/lib/common/widgets/scrollable_stack_with_indicator.dart diff --git a/app/lib/common/theme.dart b/app/lib/common/theme.dart index 65de5f25..7fdfe15c 100644 --- a/app/lib/common/theme.dart +++ b/app/lib/common/theme.dart @@ -21,7 +21,7 @@ class PharMeTheme { textTheme: textTheme, bottomSheetTheme: BottomSheetThemeData( backgroundColor: surfaceColor, - dragHandleColor: onSurfaceColor, + dragHandleColor: subheaderColor, ) ); } diff --git a/app/lib/common/widgets/scrollable_stack_with_indicator.dart b/app/lib/common/widgets/scrollable_stack_with_indicator.dart new file mode 100644 index 00000000..4f8fd7fa --- /dev/null +++ b/app/lib/common/widgets/scrollable_stack_with_indicator.dart @@ -0,0 +1,146 @@ +import '../module.dart'; + +class ScrollableStackWithIndicator extends HookWidget { + const ScrollableStackWithIndicator({ + super.key, + this.thumbColor, + this.iconColor, + this.iconSize, + this.rightScrollbarPadding, + required this.children, + }); + + final List children; + final Color? thumbColor; + final Color? iconColor; + final double? iconSize; + final double? rightScrollbarPadding; + double? _getContentHeight(GlobalKey contentKey) { + return contentKey.currentContext?.size?.height; + } + + bool? _contentScrollable( + GlobalKey contentKey, + ScrollPosition scrollPosition, + ) { + final contentHeight = _getContentHeight(contentKey); + if (contentHeight == null) return null; + return scrollPosition.viewportDimension < contentHeight; + } + + bool? _scrolledToEnd(ScrollPosition scrollPosition) { + final maxScrollOffset = scrollPosition.maxScrollExtent; + return scrollPosition.pixels >= maxScrollOffset; + } + + double? _getRelativeScrollPosition( + GlobalKey contentKey, + ScrollPosition scrollPosition, + ) { + final maxScrollOffset = scrollPosition.maxScrollExtent; + final relativePosition = + 1 - (maxScrollOffset - scrollPosition.pixels) / maxScrollOffset; + return relativePosition < 0 + ? 0 + : relativePosition > 1 + ? 1 + : relativePosition; + } + + @override + Widget build(BuildContext context) { + const scrollbarThickness = 6.5; + final scrollbarPadding = rightScrollbarPadding ?? PharMeTheme.smallSpace; + final horizontalPadding = scrollbarPadding + 3 * scrollbarThickness; + final contentKey = GlobalKey(); + final showScrollIndicatorButton = useState(false); + final scrollIndicatorButtonOpacity = useState(1); + final scrollController = useScrollController( + keepScrollOffset: false, + initialScrollOffset: 0, + ); + + void handleScrolling() { + final hideButton = _scrolledToEnd(scrollController.position) ?? false; + showScrollIndicatorButton.value = !hideButton; + final relativeScrollPosition = + _getRelativeScrollPosition(contentKey, scrollController.position); + if (relativeScrollPosition != null) { + scrollIndicatorButtonOpacity.value = 1 - relativeScrollPosition; + } + } + + useEffect(() { + scrollController.addListener(handleScrolling); + return () => scrollController.removeListener(handleScrolling); + }, [scrollController]); + + WidgetsBinding.instance.addPostFrameCallback((_) { + final contentScrollable = + _contentScrollable(contentKey, scrollController.position) ?? false; + final scrolledToEnd = + _scrolledToEnd(scrollController.position) ?? false; + showScrollIndicatorButton.value = contentScrollable && !scrolledToEnd; + }); + + return Stack( + alignment: Alignment.center, + children: [ + RawScrollbar( + controller: scrollController, // needed to always show scrollbar + thumbVisibility: true, + shape: StadiumBorder(), + padding: EdgeInsets.only( + top: PharMeTheme.mediumToLargeSpace, + right: scrollbarPadding, + ), + thumbColor: thumbColor, + thickness: scrollbarThickness, + child: SingleChildScrollView( + controller: scrollController, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + ), + child: Column( + key: contentKey, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: children, + ), + ), + ), + ), + if (showScrollIndicatorButton.value) Positioned( + bottom: 0, + child: Opacity( + opacity: scrollIndicatorButtonOpacity.value, + child: IconButton( + style: IconButton.styleFrom( + backgroundColor: Colors.white, + side: BorderSide( + color: iconColor ?? PharMeTheme.iconColor, + width: 3, + ), + ), + icon: Icon( + Icons.arrow_downward, + size: iconSize, + color: iconColor ?? PharMeTheme.iconColor, + ), + onPressed: () async { + await scrollController.animateTo( + scrollController.position.maxScrollExtent, + duration: Duration(milliseconds: 500), + curve: Curves.linearToEaseOut, + ); + showScrollIndicatorButton.value = false; + }, + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/app/lib/common/widgets/tutorial/tutorial_builder.dart b/app/lib/common/widgets/tutorial/tutorial_builder.dart index 2b7839f3..e574707f 100644 --- a/app/lib/common/widgets/tutorial/tutorial_builder.dart +++ b/app/lib/common/widgets/tutorial/tutorial_builder.dart @@ -1,4 +1,5 @@ import '../../module.dart'; +import '../scrollable_stack_with_indicator.dart'; import 'tutorial_page.dart'; class TutorialBuilder extends HookWidget { @@ -18,13 +19,20 @@ class TutorialBuilder extends HookWidget { Widget getImageAsset(String assetPath) { return Container( color: PharMeTheme.onSurfaceColor, - child: Center(child: Image.asset(assetPath)), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: PharMeTheme.largeSpace), + child: Image.asset(assetPath), + ), ); } @override Widget build(BuildContext context) { + final pageWidgets = pages.map( + (page) => _buildPageContent(context, page), + ); final currentPageIndex = useState(0); + final pageController = usePageController(initialPage: currentPageIndex.value); return Padding( padding: EdgeInsets.only( left: PharMeTheme.largeSpace, @@ -34,16 +42,27 @@ class TutorialBuilder extends HookWidget { child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, - children: _buildPageContent(context, currentPageIndex), + children: [ + Expanded( + child: PageView( + controller: pageController, + onPageChanged: (newPage) => currentPageIndex.value = newPage, + children: pageWidgets.toList(), + ), + ), + Padding( + padding: EdgeInsets.only(top: PharMeTheme.smallSpace), + child: _buildActionBar(context, currentPageIndex, pageController), + ), + ], ), ); } - List _buildPageContent( + Widget _buildPageContent( BuildContext context, - ValueNotifier currentPageIndex, + TutorialPage currentPage, ) { - final currentPage = pages[currentPageIndex.value]; final title = currentPage.title != null ? currentPage.title!(context) : null; @@ -54,95 +73,32 @@ class TutorialBuilder extends HookWidget { fontSize: PharMeTheme.textTheme.headlineSmall!.fontSize, ); final assetContainer = currentPage.assetPath != null - ? Stack( - children: [ - getImageAsset(currentPage.assetPath!), - Positioned( - top: PharMeTheme.smallSpace, - right: PharMeTheme.smallSpace, - child: IconButton.filled( - style: IconButton.styleFrom( - backgroundColor: PharMeTheme.onSurfaceText, - ), - color: PharMeTheme.onSurfaceColor, - onPressed: () async => { - await showDialog( - // ignore: use_build_context_synchronously - context: context, - builder: (context) => Dialog.fullscreen( - backgroundColor: Colors.transparent, - child: SafeArea( - child: RoundedCard( - outerHorizontalPadding: 0, - outerVerticalPadding: 0, - innerPadding: EdgeInsets.all(PharMeTheme.largeSpace), - child: Column( - children: [ - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (title != null) Expanded( - child: FittedBox( - fit: BoxFit.fitWidth, - child: Text( - title, - style: titleStyle, - ), - ), - ), - SizedBox(width: PharMeTheme.smallSpace), - GestureDetector( - onTap: () => Navigator.pop(context), - child: Icon( - Icons.close, - color: PharMeTheme.onSurfaceText, - ), - ), - ], - ), - SizedBox(height: PharMeTheme.smallToMediumSpace), - Expanded( - child: getImageAsset(currentPage.assetPath!), - ), - ], - ), - ), - ), - ), - ), - }, - icon: Icon(Icons.zoom_in), - ), - ), - ], - ) + ? getImageAsset(currentPage.assetPath!) : null; - return [ - if (title != null) Text( - title, - style: titleStyle, - ), - if (content != null) Padding( - padding: EdgeInsetsDirectional.only(top: PharMeTheme.mediumSpace), - child: content, - ), - if (assetContainer != null) Expanded( - child: Padding( + return ScrollableStackWithIndicator( + rightScrollbarPadding: 0, + thumbColor: PharMeTheme.subheaderColor, + children: [ + if (title != null) Text( + title, + style: titleStyle, + ), + if (content != null) Padding( + padding: EdgeInsetsDirectional.only(top: PharMeTheme.mediumSpace), + child: content, + ), + if (assetContainer != null) Padding( padding: EdgeInsetsDirectional.only(top: PharMeTheme.mediumSpace), child: assetContainer, ), - ), - Padding( - padding: EdgeInsetsDirectional.only(top: PharMeTheme.mediumSpace), - child: _buildActionBar(context, currentPageIndex), - ), - ]; + ], + ); } Widget _buildActionBar( BuildContext context, ValueNotifier currentPageIndex, + PageController pageController, ) { final isFirstPage = currentPageIndex.value == 0; final showFirstButton = !isFirstPage || ( @@ -169,7 +125,10 @@ class TutorialBuilder extends HookWidget { initiateRouteBack(); routeBackToContent(context.router, popNull: true); } - : () => currentPageIndex.value = currentPageIndex.value - 1, + : () => pageController.previousPage( + duration: Duration(milliseconds: 500), + curve: Curves.ease, + ), text: isFirstPage ? firstBackButtonText! : context.l10n.onboarding_prev, @@ -180,7 +139,10 @@ class TutorialBuilder extends HookWidget { direction: ButtonDirection.forward, onPressed: isLastPage ? Navigator.of(context).pop - : () => currentPageIndex.value = currentPageIndex.value + 1, + : () => pageController.nextPage( + duration: Duration(milliseconds: 500), + curve: Curves.ease, + ), text: isLastPage && lastNextButtonText != null ? lastNextButtonText! : context.l10n.action_continue, diff --git a/app/lib/common/widgets/tutorial/tutorial_controller.dart b/app/lib/common/widgets/tutorial/tutorial_controller.dart index 6b7fb0b2..6464517b 100644 --- a/app/lib/common/widgets/tutorial/tutorial_controller.dart +++ b/app/lib/common/widgets/tutorial/tutorial_controller.dart @@ -34,6 +34,7 @@ class TutorialController { isDismissible: false, isScrollControlled: true, useSafeArea: true, + useRootNavigator: true, elevation: 0, builder: (context) => TutorialBuilder( pages: pages, diff --git a/app/lib/onboarding/pages/onboarding.dart b/app/lib/onboarding/pages/onboarding.dart index 923a64ce..ea6ddc94 100644 --- a/app/lib/onboarding/pages/onboarding.dart +++ b/app/lib/onboarding/pages/onboarding.dart @@ -1,5 +1,6 @@ import '../../../common/module.dart' hide MetaData; import '../../common/models/metadata.dart'; +import '../../common/widgets/scrollable_stack_with_indicator.dart'; @RoutePage() class OnboardingPage extends HookWidget { @@ -11,8 +12,6 @@ class OnboardingPage extends HookWidget { Widget build(BuildContext context) { final pages = [ OnboardingSubPage( - availableHeight: - OnboardingDimensions.contentHeight(context, isRevisiting), illustrationPath: 'assets/images/onboarding/OutlinedLogo.png', header: context.l10n.onboarding_1_header, text: context.l10n.onboarding_1_text, @@ -20,8 +19,6 @@ class OnboardingPage extends HookWidget { bottom: PuzzleDisclaimerCard(), ), OnboardingSubPage( - availableHeight: - OnboardingDimensions.contentHeight(context, isRevisiting), illustrationPath: 'assets/images/onboarding/DrugReaction.png', header: context.l10n.onboarding_2_header, text: context.l10n.onboarding_2_text, @@ -29,8 +26,6 @@ class OnboardingPage extends HookWidget { bottom: ProfessionalDisclaimerCard(), ), OnboardingSubPage( - availableHeight: - OnboardingDimensions.contentHeight(context, isRevisiting), illustrationPath: 'assets/images/onboarding/GenomePower.png', header: context.l10n.onboarding_3_header, text: context.l10n.onboarding_3_text, @@ -38,8 +33,6 @@ class OnboardingPage extends HookWidget { bottom: PgxInfoCard(), ), OnboardingSubPage( - availableHeight: - OnboardingDimensions.contentHeight(context, isRevisiting), illustrationPath: 'assets/images/onboarding/Tailored.png', header: context.l10n.onboarding_4_header, text: context.l10n.onboarding_4_already_tested_text, @@ -49,8 +42,6 @@ class OnboardingPage extends HookWidget { ), ), OnboardingSubPage( - availableHeight: - OnboardingDimensions.contentHeight(context, isRevisiting), illustrationPath: 'assets/images/onboarding/DataProtection.png', header: context.l10n.onboarding_5_header, text: context.l10n.onboarding_5_text, @@ -293,7 +284,6 @@ class OnboardingSubPage extends HookWidget { required this.header, required this.text, required this.color, - required this.availableHeight, this.top, this.bottom, }); @@ -302,170 +292,57 @@ class OnboardingSubPage extends HookWidget { final String? secondImagePath; final String header; final String text; - final double availableHeight; final Color color; final Widget? top; final Widget? bottom; - double? _getContentHeight(GlobalKey contentKey) { - return contentKey.currentContext?.size?.height; - } - - double? _getMaxScrollOffset(GlobalKey contentKey) { - final contentHeight = _getContentHeight(contentKey); - if (contentHeight == null) return null; - return contentHeight - availableHeight; - } - - bool? _contentScrollable(GlobalKey contentKey) { - final contentHeight = _getContentHeight(contentKey); - if (contentHeight == null) return null; - return availableHeight < contentHeight; - } - - bool? _scrolledToEnd( - GlobalKey contentKey, - ScrollController scrollController, - ) { - final maxScrollOffset = _getMaxScrollOffset(contentKey); - if (maxScrollOffset == null) return null; - return scrollController.offset >= maxScrollOffset; - } - - double? _getRelativeScrollPosition( - GlobalKey contentKey, - ScrollController scrollController, - ) { - final maxScrollOffset = _getMaxScrollOffset(contentKey); - if (maxScrollOffset == null) return null; - final relativePosition = - 1 - (maxScrollOffset - scrollController.offset) / maxScrollOffset; - return relativePosition < 0 - ? 0 - : relativePosition > 1 - ? 1 - : relativePosition; - } - @override Widget build(BuildContext context) { - const scrollbarThickness = 6.5; - const iconButtonPadding = 16.0; // to align the scrollbar - const horizontalPadding = iconButtonPadding + 3 * scrollbarThickness; const imageHeight = 175.0; - final contentKey = GlobalKey(); - final showScrollIndicatorButton = useState(false); - final scrollIndicatorButtonOpacity = useState(1); - final scrollController = ScrollController(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - final contentScrollable = _contentScrollable(contentKey) ?? false; - final scrolledToEnd = _scrolledToEnd(contentKey, scrollController) ?? false; - showScrollIndicatorButton.value = contentScrollable && !scrolledToEnd; - }); - - scrollController.addListener(() { - final hideButton = _scrolledToEnd(contentKey, scrollController) ?? false; - showScrollIndicatorButton.value = !hideButton; - final relativeScrollPosition = - _getRelativeScrollPosition(contentKey, scrollController); - if (relativeScrollPosition != null) { - scrollIndicatorButtonOpacity.value = 1 - relativeScrollPosition; - } - }); - - return Stack( - alignment: Alignment.center, + return ScrollableStackWithIndicator( + iconColor: color, + thumbColor: Colors.white, + iconSize: OnboardingDimensions.iconSize, + rightScrollbarPadding: 16, children: [ - RawScrollbar( - controller: scrollController, // needed to always show scrollbar - thumbVisibility: true, - shape: StadiumBorder(), - padding: EdgeInsets.only( - top: PharMeTheme.mediumToLargeSpace, - right: iconButtonPadding, - ), - thumbColor: Colors.white, - thickness: scrollbarThickness, - child: SingleChildScrollView( - controller: scrollController, - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: horizontalPadding, - ), - child: Column( - key: contentKey, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox(height: PharMeTheme.mediumSpace), - Center( - child: FractionallySizedBox( - alignment: Alignment.topCenter, - widthFactor: 0.75, - child: Image.asset( - illustrationPath, - height: imageHeight, - ), - ), - ), - SizedBox(height: PharMeTheme.mediumToLargeSpace), - Column(children: [ - AutoSizeText( - header, - style: PharMeTheme.textTheme.headlineLarge!.copyWith( - color: Colors.white, - ), - maxLines: 2, - ), - SizedBox(height: PharMeTheme.mediumToLargeSpace), - if (top != null) ...[ - top!, - SizedBox(height: PharMeTheme.mediumSpace), - ], - Text( - text, - style: PharMeTheme.textTheme.bodyLarge!.copyWith( - color: Colors.white, - ), - ), - if (bottom != null) ...[ - SizedBox(height: PharMeTheme.mediumSpace), - bottom!, - ], - ]), - // Empty widget for spaceBetween in this column to work properly - Container(), - ], - ), + SizedBox(height: PharMeTheme.mediumSpace), + Center( + child: FractionallySizedBox( + alignment: Alignment.topCenter, + widthFactor: 0.75, + child: Image.asset( + illustrationPath, + height: imageHeight, ), ), ), - if (showScrollIndicatorButton.value) Positioned( - bottom: 0, - child: Opacity( - opacity: scrollIndicatorButtonOpacity.value, - child: IconButton( - style: IconButton.styleFrom( - backgroundColor: Colors.white, - side: BorderSide(color: color, width: 3), - ), - icon: Icon( - Icons.arrow_downward, - size: OnboardingDimensions.iconSize * 0.85, - color: color, - ), - onPressed: () async { - await scrollController.animateTo( - _getMaxScrollOffset(contentKey)!, - duration: Duration(milliseconds: 500), - curve: Curves.linearToEaseOut, - ); - showScrollIndicatorButton.value = false; - }, + SizedBox(height: PharMeTheme.mediumToLargeSpace), + Column(children: [ + AutoSizeText( + header, + style: PharMeTheme.textTheme.headlineLarge!.copyWith( + color: Colors.white, ), + maxLines: 2, ), - ), + SizedBox(height: PharMeTheme.mediumToLargeSpace), + if (top != null) ...[ + top!, + SizedBox(height: PharMeTheme.mediumSpace), + ], + Text( + text, + style: PharMeTheme.textTheme.bodyLarge!.copyWith( + color: Colors.white, + ), + ), + if (bottom != null) ...[ + SizedBox(height: PharMeTheme.mediumSpace), + bottom!, + ], + ]), + // Empty widget for spaceBetween in this column to work properly + Container(), ], ); }