diff --git a/mobile-app/lib/ui/mixins/chapter_navigation_mixin.dart b/mobile-app/lib/ui/mixins/chapter_navigation_mixin.dart new file mode 100644 index 000000000..59d8d44a9 --- /dev/null +++ b/mobile-app/lib/ui/mixins/chapter_navigation_mixin.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/models/learn/curriculum_model.dart'; +import 'package:freecodecamp/ui/mixins/navigation_mixin.dart'; +import 'package:stacked/stacked.dart'; + +mixin ChapterNavigationMixin on BaseViewModel, FloatingNavigationMixin { + @override + int findFirstAvailableIndex() { + for (int i = 0; i < items.length; i++) { + if (items[i].comingSoon != true) { + return i; + } + } + return 0; + } + + @override + int findPreviousAvailableIndex(int currentIndex) { + for (int i = currentIndex - 1; i >= 0; i--) { + if (items[i].comingSoon != true) { + return i; + } + } + return -1; + } + + @override + int findNextAvailableIndex(int currentIndex) { + for (int i = currentIndex + 1; i < items.length; i++) { + if (items[i].comingSoon != true) { + return i; + } + } + return -1; + } + + @override + int findNearestAvailableIndex(int index) { + if (index < 0 || index >= items.length) { + return findFirstAvailableIndex(); + } + + if (items[index].comingSoon != true) { + return index; + } + + // Find the nearest available index + int forward = findNextAvailableIndex(index - 1); + int backward = findPreviousAvailableIndex(index + 1); + + if (forward == -1 && backward == -1) { + return findFirstAvailableIndex(); + } else if (forward == -1) { + return backward; + } else if (backward == -1) { + return forward; + } else { + // Return the closer one + return (index - backward) <= (forward - index) ? backward : forward; + } + } + + @override + double getScrollAlignment() => 0.0; +} \ No newline at end of file diff --git a/mobile-app/lib/ui/mixins/navigation_mixin.dart b/mobile-app/lib/ui/mixins/navigation_mixin.dart new file mode 100644 index 000000000..f20e18cbc --- /dev/null +++ b/mobile-app/lib/ui/mixins/navigation_mixin.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +mixin FloatingNavigationMixin on BaseViewModel { + final ScrollController _scrollController = ScrollController(); + ScrollController get scrollController => _scrollController; + + int _currentIndex = 0; + int get currentIndex => _currentIndex; + + List _items = []; + List get items => _items; + + List _itemKeys = []; + List get itemKeys => _itemKeys; + + bool _isAnimating = false; + bool get isAnimating => _isAnimating; + + bool _isScrollListenerAttached = false; + + void setItems(List items) { + _items = items; + _itemKeys = List.generate(items.length, (index) => GlobalKey()); + _currentIndex = findFirstAvailableIndex(); + notifyListeners(); + } + + void initializeScrollListener() { + if (!_isScrollListenerAttached) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _attachScrollListener(); + }); + } + } + + void _attachScrollListener() { + if (!_isScrollListenerAttached && _scrollController.hasClients) { + _scrollController.addListener(_onScroll); + _isScrollListenerAttached = true; + } + } + + void _onScroll() { + if (!_isAnimating) { + _updateCurrentIndexFromScroll(); + } + } + + void _updateCurrentIndexFromScroll() { + if (_itemKeys.isEmpty || !_scrollController.hasClients) return; + + // Get the current scroll position + final scrollOffset = _scrollController.offset; + int newIndex = 0; + + // Simple approximation: if we can estimate item heights, we can determine which item is visible + // For now, we'll use a simple approach based on scroll position relative to total scrollable height + if (_scrollController.position.maxScrollExtent > 0) { + final scrollRatio = scrollOffset / _scrollController.position.maxScrollExtent; + newIndex = (scrollRatio * (_items.length - 1)).round(); + newIndex = newIndex.clamp(0, _items.length - 1); + } + + // Find the nearest available index + int availableIndex = findNearestAvailableIndex(newIndex); + if (availableIndex != _currentIndex) { + _currentIndex = availableIndex; + notifyListeners(); + } + } + + void scrollToPrevious() { + if (!_isAnimating) { + int prevIndex = findPreviousAvailableIndex(_currentIndex); + if (prevIndex != -1) { + _currentIndex = prevIndex; + _scrollToItem(_currentIndex); + notifyListeners(); + } + } + } + + void scrollToNext() { + if (!_isAnimating) { + int nextIndex = findNextAvailableIndex(_currentIndex); + if (nextIndex != -1) { + _currentIndex = nextIndex; + _scrollToItem(_currentIndex); + notifyListeners(); + } + } + } + + void _scrollToItem(int index) { + if (index >= 0 && index < _itemKeys.length) { + final context = _itemKeys[index].currentContext; + if (context != null) { + _isAnimating = true; + notifyListeners(); + + Scrollable.ensureVisible( + context, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + alignment: getScrollAlignment(), + ).then((_) { + _isAnimating = false; + notifyListeners(); + }); + } + } + } + + bool get hasPrevious => findPreviousAvailableIndex(_currentIndex) != -1; + bool get hasNext => findNextAvailableIndex(_currentIndex) != -1; + + int findFirstAvailableIndex() => 0; + int findPreviousAvailableIndex(int currentIndex) => currentIndex > 0 ? currentIndex - 1 : -1; + int findNextAvailableIndex(int currentIndex) => currentIndex < _items.length - 1 ? currentIndex + 1 : -1; + int findNearestAvailableIndex(int index) => index; + double getScrollAlignment() => 0.5; + + @override + void dispose() { + if (_isScrollListenerAttached) { + _scrollController.removeListener(_onScroll); + } + _scrollController.dispose(); + super.dispose(); + } +} \ No newline at end of file diff --git a/mobile-app/lib/ui/views/learn/chapter/chapter_block_view.dart b/mobile-app/lib/ui/views/learn/chapter/chapter_block_view.dart index f003d766d..96e4282f1 100644 --- a/mobile-app/lib/ui/views/learn/chapter/chapter_block_view.dart +++ b/mobile-app/lib/ui/views/learn/chapter/chapter_block_view.dart @@ -3,6 +3,7 @@ import 'package:focus_detector/focus_detector.dart'; import 'package:freecodecamp/models/learn/curriculum_model.dart'; import 'package:freecodecamp/ui/views/learn/block/block_template_view.dart'; import 'package:freecodecamp/ui/views/learn/chapter/chapter_block_viewmodel.dart'; +import 'package:freecodecamp/ui/widgets/floating_navigation_buttons.dart'; import 'package:stacked/stacked.dart'; class ChapterBlockView extends StatelessWidget { @@ -24,41 +25,59 @@ class ChapterBlockView extends StatelessWidget { appBar: AppBar( title: Text(moduleName), ), + floatingActionButton: blocks.isNotEmpty + ? FloatingNavigationButtons( + onPrevious: model.scrollToPrevious, + onNext: model.scrollToNext, + hasPrevious: model.hasPrevious, + hasNext: model.hasNext, + isAnimating: model.isAnimating, + ) + : null, body: FocusDetector( - onFocusGained: () { - model.updateUserProgress(); - }, - child: ListView.builder( - itemCount: blocks.length, - padding: const EdgeInsets.all(8), - itemBuilder: (context, index) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (model.blockOpenStates.isEmpty) { - Map openStates = { - for (var block in blocks) block.dashedName: false - }; + onFocusGained: () { + model.updateUserProgress(); + }, + child: ListView.builder( + controller: model.scrollController, + itemCount: blocks.length, + padding: const EdgeInsets.all(8), + itemBuilder: (context, index) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (model.blockOpenStates.isEmpty) { + Map openStates = { + for (var block in blocks) block.dashedName: false + }; - // Set first block open - String firstBlockKey = openStates.entries.toList()[0].key; + // Set first block open + String firstBlockKey = openStates.entries.toList()[0].key; - openStates[firstBlockKey] = true; + openStates[firstBlockKey] = true; - model.blockOpenStates = openStates; - } - }); + model.blockOpenStates = openStates; + } + + if (model.blocks.isEmpty) { + model.setBlocks(blocks); + model.initializeScrollListener(); + } + }); - return BlockTemplateView( - block: blocks[index], - isOpen: - model.blockOpenStates[blocks[index].dashedName] ?? false, - isOpenFunction: () => model.setBlockOpenClosedState( - blocks, - index, - ), - ); - }, - ), - ), + return BlockTemplateView( + key: model.blockKeys.isNotEmpty && index < model.blockKeys.length + ? model.blockKeys[index] + : ValueKey(index), + block: blocks[index], + isOpen: + model.blockOpenStates[blocks[index].dashedName] ?? false, + isOpenFunction: () => model.setBlockOpenClosedState( + blocks, + index, + ), + ); + }, + ), + ), ); }, ); diff --git a/mobile-app/lib/ui/views/learn/chapter/chapter_block_viewmodel.dart b/mobile-app/lib/ui/views/learn/chapter/chapter_block_viewmodel.dart index c73acc945..2562659d3 100644 --- a/mobile-app/lib/ui/views/learn/chapter/chapter_block_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/chapter/chapter_block_viewmodel.dart @@ -1,18 +1,27 @@ +import 'package:flutter/material.dart'; import 'package:freecodecamp/app/app.locator.dart'; import 'package:freecodecamp/models/learn/curriculum_model.dart'; import 'package:freecodecamp/service/authentication/authentication_service.dart'; +import 'package:freecodecamp/ui/mixins/navigation_mixin.dart'; import 'package:stacked/stacked.dart'; -class ChapterBlockViewmodel extends BaseViewModel { +class ChapterBlockViewmodel extends BaseViewModel with FloatingNavigationMixin { Map _blockOpenStates = {}; Map get blockOpenStates => _blockOpenStates; final authenticationService = locator(); + List get blocks => items; + List get blockKeys => itemKeys; + set blockOpenStates(Map openStates) { _blockOpenStates = openStates; notifyListeners(); } + void setBlocks(List blocks) { + setItems(blocks); + } + setBlockOpenClosedState(List blocks, int block) { Map local = blockOpenStates; Block curr = blocks[block]; diff --git a/mobile-app/lib/ui/views/learn/chapter/chapter_view.dart b/mobile-app/lib/ui/views/learn/chapter/chapter_view.dart index 6ae4d4adb..cf756735c 100644 --- a/mobile-app/lib/ui/views/learn/chapter/chapter_view.dart +++ b/mobile-app/lib/ui/views/learn/chapter/chapter_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:freecodecamp/models/learn/curriculum_model.dart'; import 'package:freecodecamp/ui/theme/fcc_theme.dart'; import 'package:freecodecamp/ui/views/learn/chapter/chapter_viewmodel.dart'; +import 'package:freecodecamp/ui/widgets/floating_navigation_buttons.dart'; import 'package:stacked/stacked.dart'; class ChapterView extends StatelessWidget { @@ -16,18 +17,28 @@ class ChapterView extends StatelessWidget { return Scaffold( backgroundColor: FccColors.gray90, appBar: AppBar( - title: Text('Chapters'), + title: const Text('Chapters'), ), + floatingActionButton: model.chapters.isNotEmpty + ? FloatingNavigationButtons( + onPrevious: model.scrollToPrevious, + onNext: model.scrollToNext, + hasPrevious: model.hasPrevious, + hasNext: model.hasNext, + isAnimating: model.isAnimating, + ) + : null, body: StreamBuilder( stream: model.auth.progress.stream, - builder: (context, snapshot) { + builder: (context, _) { return FutureBuilder( future: model.superBlockFuture, builder: (context, snapshot) { if (snapshot.hasError) { return Center( child: Text( - 'Error loading chapters: ${snapshot.error} ${snapshot.stackTrace}'), + 'Error loading chapters: ${snapshot.error} ${snapshot.stackTrace}', + ), ); } @@ -36,23 +47,37 @@ class ChapterView extends StatelessWidget { List chapters = superBlock.chapters as List; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (model.chapters.isEmpty) { + model.setChapters(chapters); + model.initializeScrollListener(); + } + }); + return ListView( + controller: model.scrollController, shrinkWrap: true, children: [ Column( children: [ - ...[ - for (Chapter chapter in chapters) - chapterBlock( - superBlock, chapter, model, context) - ] + for (int index = 0; + index < chapters.length; + index++) + Container( + key: model.chapterKeys.isNotEmpty && + index < model.chapterKeys.length + ? model.chapterKeys[index] + : ValueKey(index), + child: chapterBlock(superBlock, chapters[index], + model, context), + ) ], ), ], ); } - return Center( + return const Center( child: CircularProgressIndicator(), ); }, @@ -71,11 +96,11 @@ class ChapterView extends StatelessWidget { BuildContext context, ) { return Container( - padding: EdgeInsets.all(8), - margin: EdgeInsets.symmetric(vertical: 8, horizontal: 8), + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), decoration: BoxDecoration( color: const Color.fromRGBO(0x1b, 0x1b, 0x32, 1), - borderRadius: BorderRadius.all( + borderRadius: const BorderRadius.all( Radius.circular(10), ), ), @@ -89,7 +114,7 @@ class ChapterView extends StatelessWidget { padding: const EdgeInsets.all(8.0), child: Text( chapter.name, - style: TextStyle( + style: const TextStyle( fontSize: 28, fontWeight: FontWeight.bold, ), @@ -100,12 +125,13 @@ class ChapterView extends StatelessWidget { margin: const EdgeInsets.all(8), padding: const EdgeInsets.all(3), decoration: BoxDecoration( - border: Border.all( - color: Colors.white, - width: 2, - ), - borderRadius: BorderRadius.circular(5)), - child: Text('Coming Soon'), + border: Border.all( + color: Colors.white, + width: 2, + ), + borderRadius: BorderRadius.circular(5), + ), + child: const Text('Coming Soon'), ), for (Module module in chapter.modules as List) chapterButton(context, module, model) @@ -128,11 +154,11 @@ class ChapterView extends StatelessWidget { return Container( margin: const EdgeInsets.all(5), - constraints: BoxConstraints(minHeight: 100, maxHeight: 200), + constraints: const BoxConstraints(minHeight: 100, maxHeight: 200), width: MediaQuery.of(context).size.width * 0.90, child: TextButton( style: ButtonStyle( - padding: WidgetStatePropertyAll( + padding: const WidgetStatePropertyAll( EdgeInsets.all(12), ), alignment: Alignment.centerLeft, @@ -145,7 +171,8 @@ class ChapterView extends StatelessWidget { ), ), ), - backgroundColor: WidgetStatePropertyAll(FccColors.gray80), + backgroundColor: + const WidgetStatePropertyAll(FccColors.gray80), ), onPressed: () { model.routeToBlockView(module.blocks!, module.name); @@ -161,7 +188,7 @@ class ChapterView extends StatelessWidget { children: [ Text( module.name, - style: TextStyle( + style: const TextStyle( fontSize: 21, fontWeight: FontWeight.bold, ), @@ -189,8 +216,8 @@ class ChapterView extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( + children: const [ + Icon( Icons.arrow_forward_ios_outlined, size: 25, ) diff --git a/mobile-app/lib/ui/views/learn/chapter/chapter_viewmodel.dart b/mobile-app/lib/ui/views/learn/chapter/chapter_viewmodel.dart index 86dd5dd17..c7b80d81b 100644 --- a/mobile-app/lib/ui/views/learn/chapter/chapter_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/chapter/chapter_viewmodel.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; import 'package:freecodecamp/app/app.locator.dart'; import 'package:freecodecamp/app/app.router.dart'; import 'package:freecodecamp/models/learn/completed_challenge_model.dart'; @@ -7,20 +8,30 @@ import 'package:freecodecamp/models/main/user_model.dart'; import 'package:freecodecamp/service/authentication/authentication_service.dart'; import 'package:freecodecamp/service/dio_service.dart'; import 'package:freecodecamp/service/learn/learn_service.dart'; +import 'package:freecodecamp/ui/mixins/chapter_navigation_mixin.dart'; +import 'package:freecodecamp/ui/mixins/navigation_mixin.dart'; import 'package:stacked/stacked.dart'; import 'package:stacked_services/stacked_services.dart'; -class ChapterViewModel extends BaseViewModel { +class ChapterViewModel extends BaseViewModel + with FloatingNavigationMixin, ChapterNavigationMixin { final _dio = DioService.dio; final NavigationService _navigationService = locator(); final AuthenticationService auth = locator(); Future? superBlockFuture; + List get chapters => items; + List get chapterKeys => itemKeys; + void init() async { superBlockFuture = requestChapters(); } + void setChapters(List chapters) { + setItems(chapters); + } + Future calculateProgress(Module module) async { int steps = 0; num completedCount = 0; diff --git a/mobile-app/lib/ui/views/learn/superblock/superblock_view.dart b/mobile-app/lib/ui/views/learn/superblock/superblock_view.dart index f74c3bdb9..13f8ebff0 100644 --- a/mobile-app/lib/ui/views/learn/superblock/superblock_view.dart +++ b/mobile-app/lib/ui/views/learn/superblock/superblock_view.dart @@ -5,6 +5,7 @@ import 'package:freecodecamp/service/authentication/authentication_service.dart' import 'package:freecodecamp/ui/theme/fcc_theme.dart'; import 'package:freecodecamp/ui/views/learn/block/block_template_view.dart'; import 'package:freecodecamp/ui/views/learn/superblock/superblock_viewmodel.dart'; +import 'package:freecodecamp/ui/widgets/floating_navigation_buttons.dart'; import 'package:stacked/stacked.dart'; class SuperBlockView extends StatelessWidget { @@ -35,6 +36,26 @@ class SuperBlockView extends StatelessWidget { ); }, builder: (context, model, child) => Scaffold( + floatingActionButton: model.superBlockData != null + ? FutureBuilder( + future: model.superBlockData, + builder: (context, snapshot) { + if (snapshot.hasData && + snapshot.data is SuperBlock && + (snapshot.data as SuperBlock).blocks != null && + (snapshot.data as SuperBlock).blocks!.isNotEmpty) { + return FloatingNavigationButtons( + onPrevious: model.scrollToPrevious, + onNext: model.scrollToNext, + hasPrevious: model.hasPrevious, + hasNext: model.hasNext, + isAnimating: model.isAnimating, + ); + } + return const SizedBox.shrink(); + }, + ) + : null, body: Column( children: [ Container( @@ -93,6 +114,11 @@ class SuperBlockView extends StatelessWidget { model.blockOpenStates = openStates; } + + if (model.blocks.isEmpty) { + model.setBlocks(superBlock.blocks!); + model.initializeScrollListener(); + } }); return blockTemplate(model, superBlock); @@ -127,6 +153,7 @@ class SuperBlockView extends StatelessWidget { return true; }, child: ListView.builder( + controller: model.scrollController, padding: EdgeInsets.zero, itemCount: superBlock.blocks!.length, itemBuilder: (context, block) { @@ -138,7 +165,10 @@ class SuperBlockView extends StatelessWidget { child: Column( children: [ BlockTemplateView( - key: ValueKey(block), + key: model.blockKeys.isNotEmpty && + block < model.blockKeys.length + ? model.blockKeys[block] + : ValueKey(block), block: superBlock.blocks![block], isOpen: model.blockOpenStates[ superBlock.blocks![block].dashedName] ?? diff --git a/mobile-app/lib/ui/views/learn/superblock/superblock_viewmodel.dart b/mobile-app/lib/ui/views/learn/superblock/superblock_viewmodel.dart index 2c615e07f..8c8f25456 100644 --- a/mobile-app/lib/ui/views/learn/superblock/superblock_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/superblock/superblock_viewmodel.dart @@ -6,9 +6,10 @@ import 'package:freecodecamp/service/authentication/authentication_service.dart' import 'package:freecodecamp/service/dio_service.dart'; import 'package:freecodecamp/service/learn/learn_offline_service.dart'; import 'package:freecodecamp/service/learn/learn_service.dart'; +import 'package:freecodecamp/ui/mixins/navigation_mixin.dart'; import 'package:stacked/stacked.dart'; -class SuperBlockViewModel extends BaseViewModel { +class SuperBlockViewModel extends BaseViewModel with FloatingNavigationMixin { final _learnOfflineService = locator(); LearnOfflineService get learnOfflineService => _learnOfflineService; @@ -23,6 +24,9 @@ class SuperBlockViewModel extends BaseViewModel { Map _blockOpenStates = {}; Map get blockOpenStates => _blockOpenStates; + List get blocks => items; + List get blockKeys => itemKeys; + Future? _superBlockData; Future? get superBlockData => _superBlockData; @@ -36,6 +40,10 @@ class SuperBlockViewModel extends BaseViewModel { notifyListeners(); } + void setBlocks(List blocks) { + setItems(blocks); + } + EdgeInsets getPaddingBeginAndEnd(int index, int challenges) { if (index == 0) { return const EdgeInsets.only(top: 16); diff --git a/mobile-app/lib/ui/widgets/floating_navigation_buttons.dart b/mobile-app/lib/ui/widgets/floating_navigation_buttons.dart new file mode 100644 index 000000000..5ac5661c4 --- /dev/null +++ b/mobile-app/lib/ui/widgets/floating_navigation_buttons.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +class FloatingNavigationButtons extends StatelessWidget { + final VoidCallback? onPrevious; + final VoidCallback? onNext; + final bool hasPrevious; + final bool hasNext; + final bool isAnimating; + + const FloatingNavigationButtons({ + super.key, + this.onPrevious, + this.onNext, + required this.hasPrevious, + required this.hasNext, + this.isAnimating = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton( + heroTag: 'previous', + onPressed: (hasPrevious && !isAnimating) ? onPrevious : null, + shape: RoundedRectangleBorder( + side: const BorderSide( + width: 1, + color: Colors.white, + ), + borderRadius: BorderRadius.circular(100), + ), + backgroundColor: (hasPrevious && !isAnimating) + ? const Color.fromRGBO(0x2A, 0x2A, 0x40, 1) + : const Color.fromRGBO(0x2A, 0x2A, 0x40, 0.5), + child: Icon( + Icons.keyboard_arrow_up, + size: 30, + color: (hasPrevious && !isAnimating) ? Colors.white : Colors.grey, + ), + ), + const SizedBox(height: 12), + FloatingActionButton( + heroTag: 'next', + onPressed: (hasNext && !isAnimating) ? onNext : null, + shape: RoundedRectangleBorder( + side: const BorderSide( + width: 1, + color: Colors.white, + ), + borderRadius: BorderRadius.circular(100), + ), + backgroundColor: (hasNext && !isAnimating) + ? const Color.fromRGBO(0x2A, 0x2A, 0x40, 1) + : const Color.fromRGBO(0x2A, 0x2A, 0x40, 0.5), + child: Icon( + Icons.keyboard_arrow_down, + size: 30, + color: (hasNext && !isAnimating) ? Colors.white : Colors.grey, + ), + ), + ], + ); + } +} diff --git a/mobile-app/test/ui/widgets/floating_navigation_buttons_test.dart b/mobile-app/test/ui/widgets/floating_navigation_buttons_test.dart new file mode 100644 index 000000000..bee3dfd16 --- /dev/null +++ b/mobile-app/test/ui/widgets/floating_navigation_buttons_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:freecodecamp/ui/widgets/floating_navigation_buttons.dart'; + +void main() { + testWidgets('FloatingNavigationButtons should render correctly', (WidgetTester tester) async { + bool previousPressed = false; + bool nextPressed = false; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + floatingActionButton: FloatingNavigationButtons( + onPrevious: () => previousPressed = true, + onNext: () => nextPressed = true, + hasPrevious: true, + hasNext: true, + ), + ), + )); + + expect(find.byType(FloatingActionButton), findsExactly(2)); + + expect(find.byIcon(Icons.keyboard_arrow_up), findsOneWidget); + expect(find.byIcon(Icons.keyboard_arrow_down), findsOneWidget); + + await tester.tap(find.byIcon(Icons.keyboard_arrow_up)); + await tester.tap(find.byIcon(Icons.keyboard_arrow_down)); + + expect(previousPressed, true); + expect(nextPressed, true); + }); + + testWidgets('FloatingNavigationButtons should disable buttons when appropriate', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + floatingActionButton: FloatingNavigationButtons( + onPrevious: () {}, + onNext: () {}, + hasPrevious: false, + hasNext: false, + ), + ), + )); + + final floatingActionButtons = tester.widgetList( + find.byType(FloatingActionButton), + ).toList(); + + expect(floatingActionButtons.length, 2); + expect(floatingActionButtons[0].onPressed, null); + expect(floatingActionButtons[1].onPressed, null); + }); + + testWidgets('FloatingNavigationButtons should disable buttons when animating', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + floatingActionButton: FloatingNavigationButtons( + onPrevious: () {}, + onNext: () {}, + hasPrevious: true, + hasNext: true, + isAnimating: true, + ), + ), + )); + + final floatingActionButtons = tester.widgetList( + find.byType(FloatingActionButton), + ).toList(); + + expect(floatingActionButtons.length, 2); + expect(floatingActionButtons[0].onPressed, null); + expect(floatingActionButtons[1].onPressed, null); + }); +} \ No newline at end of file