Skip to content

Commit

Permalink
feat(app): use scroll indicator for tutorial
Browse files Browse the repository at this point in the history
  • Loading branch information
tamslo committed Feb 25, 2025
1 parent 04150f6 commit d930901
Show file tree
Hide file tree
Showing 5 changed files with 236 additions and 250 deletions.
2 changes: 1 addition & 1 deletion app/lib/common/theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class PharMeTheme {
textTheme: textTheme,
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: surfaceColor,
dragHandleColor: onSurfaceColor,
dragHandleColor: subheaderColor,
)
);
}
Expand Down
146 changes: 146 additions & 0 deletions app/lib/common/widgets/scrollable_stack_with_indicator.dart
Original file line number Diff line number Diff line change
@@ -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<Widget> 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<double>(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;
},
),
),
),
],
);
}
}
136 changes: 49 additions & 87 deletions app/lib/common/widgets/tutorial/tutorial_builder.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import '../../module.dart';
import '../scrollable_stack_with_indicator.dart';
import 'tutorial_page.dart';

class TutorialBuilder extends HookWidget {
Expand All @@ -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,
Expand All @@ -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<Widget> _buildPageContent(
Widget _buildPageContent(
BuildContext context,
ValueNotifier<int> currentPageIndex,
TutorialPage currentPage,
) {
final currentPage = pages[currentPageIndex.value];
final title = currentPage.title != null
? currentPage.title!(context)
: null;
Expand All @@ -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<int> currentPageIndex,
PageController pageController,
) {
final isFirstPage = currentPageIndex.value == 0;
final showFirstButton = !isFirstPage || (
Expand All @@ -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,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions app/lib/common/widgets/tutorial/tutorial_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class TutorialController {
isDismissible: false,
isScrollControlled: true,
useSafeArea: true,
useRootNavigator: true,
elevation: 0,
builder: (context) => TutorialBuilder(
pages: pages,
Expand Down
Loading

0 comments on commit d930901

Please sign in to comment.