Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions mobile-app/lib/ui/mixins/chapter_navigation_mixin.dart
Original file line number Diff line number Diff line change
@@ -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<Chapter> {
@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;
}
132 changes: 132 additions & 0 deletions mobile-app/lib/ui/mixins/navigation_mixin.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';

mixin FloatingNavigationMixin<T> on BaseViewModel {
final ScrollController _scrollController = ScrollController();
ScrollController get scrollController => _scrollController;

int _currentIndex = 0;
int get currentIndex => _currentIndex;

List<T> _items = [];
List<T> get items => _items;

List<GlobalKey> _itemKeys = [];
List<GlobalKey> get itemKeys => _itemKeys;

bool _isAnimating = false;
bool get isAnimating => _isAnimating;

bool _isScrollListenerAttached = false;

void setItems(List<T> 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();
}
}
79 changes: 49 additions & 30 deletions mobile-app/lib/ui/views/learn/chapter/chapter_block_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<String, bool> 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<String, bool> 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,
),
);
},
),
),
);
},
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Block> {
Map<String, bool> _blockOpenStates = {};
Map<String, bool> get blockOpenStates => _blockOpenStates;
final authenticationService = locator<AuthenticationService>();

List<Block> get blocks => items;
List<GlobalKey> get blockKeys => itemKeys;

set blockOpenStates(Map<String, bool> openStates) {
_blockOpenStates = openStates;
notifyListeners();
}

void setBlocks(List<Block> blocks) {
setItems(blocks);
}

setBlockOpenClosedState(List<Block> blocks, int block) {
Map<String, bool> local = blockOpenStates;
Block curr = blocks[block];
Expand Down
Loading
Loading