diff --git a/example/lib/main.dart b/example/lib/main.dart index ae3cbc8..2498110 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -102,13 +102,14 @@ class HomePage extends ConsumerStatefulWidget { } class _HomePageState extends ConsumerState { + bool swipeable = false; final Map> _routes = const { 0: { '/': HomeFeeds(), FeedDetail.route: FeedDetail(), }, 1: { - '/': ProductList(), + '/': ProductPage(), ProductDetail.route: ProductDetail(), ProductComments.route: ProductComments(), }, @@ -193,6 +194,9 @@ class _HomePageState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const SizedBox( + + + width: 60, ), FloatingActionButton.extended( @@ -205,6 +209,7 @@ class _HomePageState extends ConsumerState { ), const SizedBox( width: 20, + ), FloatingActionButton.extended( heroTag: 'showSnackBar', @@ -234,6 +239,15 @@ class _HomePageState extends ConsumerState { setState(() {}); }, ), + FloatingActionButton( + heroTag: 'swipe', + child: Icon( + swipeable ? Icons.swipe : Icons.touch_app_outlined), + onPressed: () { + swipeable = !swipeable; + setState(() {}); + }, + ), FloatingActionButton( heroTag: 'darkmode', child: Icon(appSetting.isDarkMode @@ -252,6 +266,14 @@ class _HomePageState extends ConsumerState { }), body: Builder(builder: (context) { return NavbarRouter( + swipeable: swipeable, + // swipeableLeftArea: Rect.fromLTWH( + // 0, 50, 50, MediaQuery.of(context).size.height * 0.9), + // swipeableRightArea: Rect.fromLTWH( + // MediaQuery.of(context).size.width - 50, + // 50, + // 50, + // MediaQuery.of(context).size.height * 0.9), errorBuilder: (context) { return const Center(child: Text('Error 404')); }, @@ -408,7 +430,7 @@ class FeedTile extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - height: 300, + height: 400, margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8), color: Theme.of(context).colorScheme.surface, child: Card( @@ -468,6 +490,86 @@ class FeedDetail extends StatelessWidget { } } +class ProductPage extends ConsumerStatefulWidget { + const ProductPage({super.key}); + static const String route = '/'; + + @override + ConsumerState createState() => _ProductPageState(); +} + +class _ProductPageState extends ConsumerState { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Products'), + ), + body: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 5, + // controller: _scrollController, + itemBuilder: (context, index) { + return Container( + height: 60, + color: Colors.redAccent.shade100, + padding: const EdgeInsets.all(8.0), + child: InkWell( + onTap: () { + if (index == 0) { + NavbarNotifier.pushNamed(FeedDetail.route, 0); + NavbarNotifier.showSnackBar( + context, 'switching to Home', onClosed: () { + NavbarNotifier.index = 0; + }); + } else { + NavbarNotifier.hideBottomNavBar = false; + Navigate.pushNamed(context, ProductDetail.route, + transitionType: TransitionType.scale, + arguments: {'id': index.toString()}); + } + }, + child: Card( + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 12), + height: 60, + width: 120, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + margin: const EdgeInsets.all(8), + height: 15, + width: 15, + color: Theme.of(context) + .colorScheme + .secondary, + ), + Flexible( + child: Text( + index != 0 + ? 'Product $index' + : 'Tap to push a route on HomePage Programmatically', + textAlign: TextAlign.end, + ), + ), + ], + )), + )), + ); + }), + ), + const Expanded(flex: 2, child: ProductList()), + ], + )); + } +} + class ProductList extends ConsumerStatefulWidget { const ProductList({super.key}); static const String route = '/'; @@ -521,34 +623,29 @@ class _ProductListState extends ConsumerState { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Products'), - ), - body: ListView.builder( - controller: _scrollController, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: InkWell( - onTap: () { - if (index == 0) { - NavbarNotifier.pushNamed(FeedDetail.route, 0); - NavbarNotifier.showSnackBar(context, 'switching to Home', - onClosed: () { - NavbarNotifier.index = 0; - }); - } else { - NavbarNotifier.hideBottomNavBar = false; - Navigate.pushNamed(context, ProductDetail.route, - transitionType: TransitionType.scale, - arguments: {'id': index.toString()}); - } - }, - child: ProductTile(index: index)), - ); - }), - ); + return ListView.builder( + controller: _scrollController, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: InkWell( + onTap: () { + if (index == 0) { + NavbarNotifier.pushNamed(FeedDetail.route, 0); + NavbarNotifier.showSnackBar(context, 'switching to Home', + onClosed: () { + NavbarNotifier.index = 0; + }); + } else { + NavbarNotifier.hideBottomNavBar = false; + Navigate.pushNamed(context, ProductDetail.route, + transitionType: TransitionType.scale, + arguments: {'id': index.toString()}); + } + }, + child: ProductTile(index: index)), + ); + }); } } diff --git a/lib/src/gestures.dart b/lib/src/gestures.dart new file mode 100644 index 0000000..dedcdd9 --- /dev/null +++ b/lib/src/gestures.dart @@ -0,0 +1,96 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first, avoid_print +import 'package:flutter/material.dart'; +import 'package:navbar_router/navbar_router.dart'; +import 'package:navbar_router/src/navbar_swipeable_utls.dart'; + +class Gesture { + bool dragging = false; + int pageViewIndex = 0; + late PageController pageController; + double Function() getPadding; + BuildContext context; + Gesture({ + required this.getPadding, + required this.context, + }); + + /// Swipeable functions below + // convert scrollable pixels to current index + double getPageFromPixels(context) { + return pageController.offset / + (MediaQuery.of(context).size.width - getPadding()); + } + + double getPixelsFromPage(int page) { + return (MediaQuery.of(context).size.width - getPadding()) * page; + } + + // control when user can swipe to other page + bool handleOverscroll(OverscrollNotification value) { + if (!dragging) return false; + print(value.overscroll); + if (value.overscroll < 0 && pageController.offset + value.overscroll <= 0) { + if (pageController.offset != 0) { + pageController.jumpTo(0); + } + return true; + } + if (pageController.offset + value.overscroll >= + pageController.position.maxScrollExtent) { + if (pageController.offset != pageController.position.maxScrollExtent) { + pageController.jumpTo(pageController.position.maxScrollExtent); + } + return true; + } + pageController.jumpTo(pageController.offset + value.overscroll); + + return true; + } + + void onDragStart(details) { + if (dragging) return; + if (details.localPosition.dx <= kDragAreaWidth || + details.localPosition.dx >= + MediaQuery.of(context).size.width - kDragAreaWidth) { + dragging = true; + } + } + + double? onDragUpdate(DragUpdateDetails details) { + // print(details.delta); + double? newOffset; + if (dragging) { + var page = getPageFromPixels(context); + // print(page); + if ((page == 0 && details.delta.dx > 0.1) || + (page >= NavbarNotifier.length - 1 && details.delta.dx < -0.1)) { + return null; + } + double newOffset = pageController.offset - details.delta.dx; + print(newOffset / getPixelsFromPage(NavbarNotifier.currentIndex)); + + // handle fade animation when swiping + + pageController.jumpTo(newOffset); + } + return newOffset; + } + + int onDragEnd(details) { + print(pageController.offset); + // when user release the drag, we calculate which page they're on + var page = getPageFromPixels(context); + print(page); + int value = page.round(); + if (value < 0) { + pageController.animateTo(pageController.position.minScrollExtent, + duration: Durations.long1, curve: Curves.ease); + } else if (value >= NavbarNotifier.length) { + pageController.animateTo(pageController.position.maxScrollExtent, + duration: Durations.long1, curve: Curves.ease); + } else {} + + dragging = false; + return value; + } +} diff --git a/lib/src/navbar_router.dart b/lib/src/navbar_router.dart index b9329d9..236b5e0 100644 --- a/lib/src/navbar_router.dart +++ b/lib/src/navbar_router.dart @@ -1,7 +1,14 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first, avoid_print + +import 'dart:math'; + import 'package:badges/badges.dart' as badges; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; + import 'package:navbar_router/navbar_router.dart'; +import 'package:navbar_router/src/gestures.dart'; +import 'package:navbar_router/src/navbar_swipeable_utls.dart'; part 'animated_navbar.dart'; @@ -152,6 +159,16 @@ class NavbarRouter extends StatefulWidget { /// Set to true will hide the badges when the tap on the navbar icon. final bool hideBadgeOnPageChanged; + /// Set to true will opt-in to horizontally swipeable navigation bar. + final bool swipeable; + + /// Configure the swipeable area on the of the screen + /// + /// By default, it will be: left center aligned, width = 50 pixels, top = 50 pixels, height = 0.8 x screen height + /// + /// Only take effect if **[swipeable]** is set to true + final Rect? swipeableArea; + /// Take a look at the [readme](https://github.com/maheshmnj/navbar_router) for more information on how to use this package. /// /// Please help me improve this package. @@ -162,6 +179,8 @@ class NavbarRouter extends StatefulWidget { /// const NavbarRouter( {Key? key, + this.swipeable = false, + this.swipeableArea, required this.destinations, required this.errorBuilder, this.shouldPopToBaseRoute = true, @@ -189,6 +208,7 @@ class _NavbarRouterState extends State final List items = []; late List fadeAnimation; List> keys = []; + late Gesture pageGesture; @override void initState() { @@ -216,6 +236,29 @@ class _NavbarRouterState extends State NavbarNotifier.makeBadgeVisible(NavbarNotifier.currentIndex, true); initAnimation(); NavbarNotifier.index = widget.initialIndex; + + // necessary init for swipeable + pageGesture = Gesture(context: context, getPadding: getPadding); + pageGesture.pageController = + PageController(initialPage: widget.initialIndex); + + NavbarNotifier.addIndexChangeListener( + (newIndex) { + if (!mounted) return; + // print(newIndex); + + if (widget.swipeable) { + pageGesture.pageController.animateTo( + pageGesture.getPixelsFromPage(newIndex), + duration: Durations.long1, + curve: Curves.ease); + } else { + pageGesture.pageController.jumpTo( + pageGesture.getPixelsFromPage(newIndex), + ); + } + }, + ); } void updateWidget() { @@ -231,7 +274,7 @@ class _NavbarRouterState extends State fadeAnimation = items.map((NavbarItem item) { return AnimationController( vsync: this, - value: item == items[widget.initialIndex] ? 1.0 : 0.0, + value: item == items[widget.initialIndex] ? 1.0 : 0, duration: Duration(milliseconds: widget.destinationAnimationDuration)); }).toList(); @@ -336,6 +379,8 @@ class _NavbarRouterState extends State } } + // Swipeable page + @override Widget build(BuildContext context) { return PopScope( @@ -347,6 +392,12 @@ class _NavbarRouterState extends State final bool isExitingApp = NavbarNotifier.onBackButtonPressed( behavior: widget.backButtonBehavior); widget.onBackButtonPressed!(isExitingApp); + + // callback onchange when going back the route stack + if (widget.backButtonBehavior == BackButtonBehavior.rememberHistory && + widget.onChanged != null) { + widget.onChanged!(NavbarNotifier.currentIndex); + } _handleFadeAnimation(); }, child: AnimatedBuilder( @@ -359,10 +410,25 @@ class _NavbarRouterState extends State /// same duration as [_AnimatedNavbar]'s animation duration duration: const Duration(milliseconds: 500), padding: EdgeInsets.only(left: getPadding()), - child: Stack(children: [ - for (int i = 0; i < NavbarNotifier.length; i++) - _buildIndexedStackItem(i, context) - ]), + child: ListView.builder( + itemCount: NavbarNotifier.length, + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + controller: pageGesture.pageController, + itemBuilder: (context, i) { + // use keep-alive to prevent list builder to rebuild + return KeepAliveWrapper( + keepAlive: true, + child: NotificationListener( + onNotification: widget.swipeable + ? pageGesture.handleOverscroll + : null, + child: SizedBox( + width: MediaQuery.of(context).size.width - + getPadding(), + child: _buildIndexedStackItem(i, context))), + ); + }), ), Positioned( left: 0, @@ -397,6 +463,96 @@ class _NavbarRouterState extends State }, menuItems: items), ), + + // swipe area + Positioned.fromRect( + rect: !widget.swipeable + ? Rect.zero + : widget.swipeableArea ?? + Rect.fromLTWH( + getPadding(), + kDragAreaTop, + kDragAreaWidth, + MediaQuery.of(context).size.width * + kDragAreaHeightFactor), + child: GestureDetector( + key: const ObjectKey("swipe-left"), + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: (details) { + if (!mounted) return; + + pageGesture.onDragStart(details); + }, + onHorizontalDragUpdate: (details) { + if (!mounted) return; + + var newOffset = pageGesture.onDragUpdate(details); + if (newOffset == null) return; + + for (int i = 0; i < fadeAnimation.length; i++) { + if (i != NavbarNotifier.currentIndex) { + var distanceFromCurrentPage = + pageGesture.getPixelsFromPage( + NavbarNotifier.currentIndex) - + newOffset; + + fadeAnimation[i].value = min( + 1.0, + distanceFromCurrentPage.abs() / + (MediaQuery.of(context).size.width - + getPadding())); + } else { + fadeAnimation[i].value = (newOffset / + pageGesture.getPixelsFromPage( + NavbarNotifier.currentIndex)) + .clamp(0.5, 1); + } + } + }, + onHorizontalDragEnd: (details) { + if (!mounted) { + pageGesture.dragging = false; + return; + } + + var value = pageGesture.onDragEnd(details); + if (value >= 0 && value < NavbarNotifier.length) { + NavbarNotifier.index = value; + if (widget.onChanged != null) { + widget.onChanged!(value); + } + _handleFadeAnimation(); + } + }, + ), + ), + + // swipe right area + // Positioned.fromRect( + // key: const ObjectKey("swipe-right"), + // rect: !widget.swipeable + // ? Rect.zero + // : widget.swipeableRightArea ?? + // Rect.fromLTWH( + // MediaQuery.of(context).size.width - + // kDragAreaWidth, + // kDragAreaTop, + // kDragAreaWidth, + // MediaQuery.of(context).size.height * + // kDragAreaHeightFactor), + // child: GestureDetector( + // behavior: HitTestBehavior.translucent, + // onHorizontalDragStart: (details) { + // onDragStart(details); + // }, + // onHorizontalDragUpdate: (details) { + // onDragUpdate(details); + // }, + // onHorizontalDragEnd: (details) { + // onDragEnd(details); + // }, + // ), + // ) ], ); })); diff --git a/lib/src/navbar_swipeable_utls.dart b/lib/src/navbar_swipeable_utls.dart new file mode 100644 index 0000000..58cb2fc --- /dev/null +++ b/lib/src/navbar_swipeable_utls.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +const double kDragAreaTop = 50; +const double kDragAreaWidth = 50; +const double kOpacityWhenSwipeable = 0.5; +const double kDragAreaHeightFactor = 0.8; + +class KeepAliveWrapper extends StatefulWidget { + final Widget child; + final bool keepAlive; + const KeepAliveWrapper({ + Key? key, + required this.child, + required this.keepAlive, + }) : super(key: key); + @override + KeepAliveWrapperState createState() => KeepAliveWrapperState(); +} + +class KeepAliveWrapperState extends State + with AutomaticKeepAliveClientMixin { + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } + + // Setting to true will force the tab to never be disposed. This could be dangerous. + @override + bool get wantKeepAlive => widget.keepAlive; +} diff --git a/test/navbar_router_test.dart b/test/navbar_router_test.dart index 494b827..58a55ab 100644 --- a/test/navbar_router_test.dart +++ b/test/navbar_router_test.dart @@ -117,6 +117,7 @@ void main() { child: MediaQuery( data: MediaQueryData(size: size), child: NavbarRouter( + swipeable: true, errorBuilder: (context) { return const Center(child: Text('Error 404')); }, @@ -623,8 +624,8 @@ void main() { expect(tapTargetText.textX(), findsOneWidget); await tester.tap(tapTargetText.textX()); await tester.pumpAndSettle(); - expect('switching to Home'.textX(), findsNWidgets(items.length)); - expect(find.byType(SnackBar), findsNWidgets(items.length)); + expect('switching to Home'.textX(), findsNWidgets(1)); + expect(find.byType(SnackBar), findsNWidgets(1)); await tester.pumpAndSettle(const Duration(seconds: 4)); final feedRoute = routes[0]!['/'].runtimeType.typeX(); final feedDetailRoute = @@ -1529,6 +1530,7 @@ void main() { // WARNING: Snackbar Test are written considering snackbars are shown across all the tabs // e.g if a snackbar is shown it will be displayed items.length times // This is because we have migrated to Stack inplace of IndexedStack + // bebaoboy: I'm changing this back to 1 because I use ListView for the swipeable navbar group('Snackbar should be controlled using NavbarNotifier:', skip: false, () { testWidgets('Snackbar should be shown', (WidgetTester tester) async { await tester.pumpWidget(boilerplate()); @@ -1536,7 +1538,7 @@ void main() { final BuildContext context = tester.element(find.byType(NavbarRouter)); NavbarNotifier.showSnackBar(context, "This is a Snackbar message"); await tester.pumpAndSettle(); - expect(find.byType(SnackBar), findsNWidgets(items.length)); + expect(find.byType(SnackBar), findsNWidgets(1)); }); testWidgets('Snackbar should be hidden', (WidgetTester tester) async { @@ -1546,7 +1548,7 @@ void main() { NavbarNotifier.showSnackBar(context, "This is a Snackbar message", duration: const Duration(seconds: 5)); await tester.pumpAndSettle(); - expect(find.byType(SnackBar), findsNWidgets(items.length)); + expect(find.byType(SnackBar), findsNWidgets(1)); NavbarNotifier.hideSnackBar(context); await tester.pumpAndSettle(); expect(find.byType(SnackBar), findsNothing); @@ -1560,9 +1562,9 @@ void main() { NavbarNotifier.showSnackBar(context, "This is a Snackbar message", duration: const Duration(seconds: 5)); await tester.pumpAndSettle(); - expect(find.byType(SnackBar), findsNWidgets(items.length)); + expect(find.byType(SnackBar), findsNWidgets(1)); await tester.pumpAndSettle(const Duration(seconds: 4)); - expect(find.byType(SnackBar), findsNWidgets(items.length)); + expect(find.byType(SnackBar), findsNWidgets(1)); await tester.pumpAndSettle(const Duration(seconds: 1)); expect(find.byType(SnackBar), findsNothing); }); @@ -1577,9 +1579,9 @@ void main() { duration: const Duration(seconds: 5), ); await tester.pumpAndSettle(); - expect(find.byType(SnackBar), findsNWidgets(items.length)); + expect(find.byType(SnackBar), findsNWidgets(1)); await tester.tap(find.byIcon(Icons.close).first); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(const Duration(seconds: 5)); expect(find.byType(SnackBar), findsNothing); }); @@ -1597,9 +1599,9 @@ void main() { }, ); await tester.pumpAndSettle(); - expect(find.byType(SnackBar), findsNWidgets(items.length)); + expect(find.byType(SnackBar), findsNWidgets(1)); await tester.tap(find.byIcon(Icons.close).first); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(const Duration(seconds: 5)); expect(find.byType(SnackBar), findsNothing); expect(isClosed, true); }); @@ -1618,7 +1620,7 @@ void main() { duration: const Duration(seconds: 5), ); await tester.pumpAndSettle(); - expect(find.byType(SnackBar), findsNWidgets(items.length)); + expect(find.byType(SnackBar), findsNWidgets(1)); await tester.tap(find.text("Tap me").first); await tester.pumpAndSettle(); expect(find.byType(SnackBar), findsNothing); @@ -1793,4 +1795,103 @@ void main() { await tester.pumpAndSettle(); expect((navItems[2].selectedIcon as Icon).color, Colors.green); }); + + group('Swipeable extra test', () { + testWidgets('Should not be able to drag on center', (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 0)); + await tester.pumpAndSettle(); + + await tester.dragFrom( + const Offset(400, 350), const Offset(900, 0)); + await tester.pumpAndSettle(); + + expect(NavbarNotifier.currentIndex, equals(0)); + expect('Feed 0 card'.textX(), findsOneWidget); + }); + + testWidgets('Should be able to drag left', (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 1)); + await tester.pumpAndSettle(); + + await tester.drag( + find.byKey(const ObjectKey("swipe-left")), const Offset(900, 0)); + await tester.pumpAndSettle(); + + expect(NavbarNotifier.currentIndex, equals(0)); + expect('Feed 0 card'.textX(), findsOneWidget); + }); + + testWidgets('Should be able to fling left', (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 2)); + await tester.pumpAndSettle(); + + await tester.fling( + find.byKey(const ObjectKey("swipe-left")), const Offset(900, 0), 20); + await tester.pumpAndSettle(); + + expect(NavbarNotifier.currentIndex, equals(1)); + expect('Product 1'.textX(), findsOneWidget); + }); + + testWidgets('Should not be able to fling left at index 0', + (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 0)); + await tester.pumpAndSettle(); + + await tester.fling(find.byKey(const ObjectKey("swipe-left")), + const Offset(99900, 0), 20); + await tester.pumpAndSettle(); + + expect(NavbarNotifier.currentIndex, equals(0)); + expect('Feed 0 card'.textX(), findsOneWidget); + + await tester.drag( + find.byKey(const ObjectKey("swipe-left")), const Offset(99900, 0)); + await tester.pumpAndSettle(); + + expect(NavbarNotifier.currentIndex, equals(0)); + expect('Feed 0 card'.textX(), findsOneWidget); + }); + + testWidgets('Should be able to drag right', (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 0)); + await tester.pumpAndSettle(); + + await tester.drag( + find.byKey(const ObjectKey("swipe-left")), const Offset(-900, 0)); + await tester.pumpAndSettle(); + + expect(NavbarNotifier.currentIndex, equals(1)); + expect('Product 1'.textX(), findsOneWidget); + }); + + testWidgets('Should be able to fling right', (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 0)); + await tester.pumpAndSettle(); + + await tester.fling(find.byKey(const ObjectKey("swipe-left")), + const Offset(-900, 0), 20); + await tester.pumpAndSettle(); + + expect(NavbarNotifier.currentIndex, equals(1)); + expect('Product 1'.textX(), findsOneWidget); + }); + + testWidgets('Should not be able to fling right at last index', + (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 3)); + await tester.pumpAndSettle(); + + await tester.fling(find.byKey(const ObjectKey("swipe-left")), + const Offset(-99900, 0), 20); + await tester.pumpAndSettle(); + + await tester.drag( + find.byKey(const ObjectKey("swipe-left")), const Offset(-99900, 0)); + await tester.pumpAndSettle(); + + expect(NavbarNotifier.currentIndex, equals(3)); + expect('Slide to change theme color'.textX(), findsOneWidget); + }); + }); }