Last active
April 18, 2025 00:29
-
-
Save hungify/af1547c6d18c6a5f78a9587e7e62d8a7 to your computer and use it in GitHub Desktop.
Infinite Scroll to Top In Flutter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'package:flutter/material.dart'; | |
import 'package:intl/intl.dart'; | |
import 'package:scrollview_observer/scrollview_observer.dart'; | |
void main() => runApp(const MyApp()); | |
class MyApp extends StatelessWidget { | |
const MyApp({super.key}); | |
@override | |
Widget build(BuildContext context) { | |
return const MaterialApp( | |
home: ProgressJourneyPage(), | |
); | |
} | |
} | |
class ProgressJourneyPage extends StatefulWidget { | |
const ProgressJourneyPage({super.key}); | |
@override | |
State<ProgressJourneyPage> createState() => _ProgressJourneyPageState(); | |
} | |
class _ProgressJourneyPageState extends State<ProgressJourneyPage> { | |
final scrollController = ScrollController(); | |
late final ListObserverController observerController; | |
List<WeekData> journey = []; | |
int _page = 0; | |
final int _pageSize = 5; | |
int _totalCount = 0; | |
bool _loadingMore = false; | |
bool hasMoreData = true; | |
bool _showLoadingScroll = false; | |
@override | |
void initState() { | |
super.initState(); | |
observerController = ListObserverController(controller: scrollController); | |
scrollController.addListener(_handleScrollUp); | |
_loadMoreData(force: true); | |
WidgetsBinding.instance.addPostFrameCallback((_) async { | |
await _ensureCurrentWeekVisible(); | |
await _ensureScrollable(); | |
}); | |
} | |
int findCurrentWeekIndex(List<WeekData> journey) { | |
return journey | |
.indexWhere((w) => w.sessions.any((s) => s.status == 'current')); | |
} | |
Future<void> _ensureCurrentWeekVisible() async { | |
// Tìm index của tuần có session status == 'current' trong journey đã load | |
int journeyCurrentIndex = findCurrentWeekIndex(journey); | |
// Nếu chưa thấy tuần current và còn dữ liệu, tiếp tục load thêm | |
while (journeyCurrentIndex == -1 && hasMoreData) { | |
await _loadMoreData(force: true); | |
journeyCurrentIndex = findCurrentWeekIndex(journey); | |
} | |
if (journeyCurrentIndex == -1) { | |
journeyCurrentIndex = journey.length - 1; | |
} | |
await Future.delayed(const Duration(milliseconds: 50)); | |
observerController.animateTo( | |
index: journeyCurrentIndex, | |
duration: const Duration(milliseconds: 500), | |
curve: Curves.easeInOut, | |
alignment: 0, | |
); | |
} | |
Future<void> _ensureScrollable() async { | |
await Future.delayed(const Duration(milliseconds: 50)); | |
setState(() { | |
_showLoadingScroll = true; | |
}); | |
while (scrollController.hasClients && | |
scrollController.position.maxScrollExtent == 0 && | |
hasMoreData) { | |
await _loadMoreData(force: true); | |
await Future.delayed(const Duration(milliseconds: 50)); | |
} | |
setState(() { | |
_showLoadingScroll = false; | |
}); | |
} | |
Future<Map<String, dynamic>> fetchJourneyFromServer( | |
int page, int pageSize) async { | |
await Future.delayed(const Duration(milliseconds: 100)); | |
final int start = page * pageSize; | |
final int end = (start + pageSize).clamp(0, mockData.length); | |
final results = mockData.sublist(start, end); | |
return { | |
'count': mockData.length, | |
'results': results, | |
}; | |
} | |
Future<void> _loadMoreData({bool force = false}) async { | |
if (_loadingMore) return; | |
_loadingMore = true; | |
final nextPage = _page; | |
final data = await fetchJourneyFromServer(nextPage, _pageSize); | |
final List<WeekData> newWeeks = List<WeekData>.from(data['results']); | |
_totalCount = data['count'] as int; | |
setState(() { | |
journey.addAll(newWeeks); | |
_page++; | |
hasMoreData = journey.length < _totalCount; | |
}); | |
if (!force) { | |
WidgetsBinding.instance.addPostFrameCallback((_) { | |
observerController.dispatchOnceObserve(); | |
observerController.jumpTo( | |
index: journey.length - 10, | |
alignment: 0, | |
); | |
_loadingMore = false; | |
}); | |
} else { | |
_loadingMore = false; | |
} | |
} | |
void _handleScrollUp() { | |
if (scrollController.offset >= scrollController.position.maxScrollExtent && | |
!_loadingMore && | |
hasMoreData) { | |
_loadingMore = true; | |
_loadMoreData(); | |
} | |
} | |
@override | |
Widget build(BuildContext context) { | |
final journeyList = journey.toList(); | |
if (journeyList.isEmpty) { | |
if (_loadingMore) { | |
return const Scaffold( | |
body: Center( | |
child: CircularProgressIndicator(), | |
), | |
); | |
} else { | |
return const Scaffold( | |
body: Center( | |
child: Text('Không có dữ liệu'), | |
), | |
); | |
} | |
} | |
return Scaffold( | |
body: Padding( | |
padding: const EdgeInsets.all(32), | |
child: Stack( | |
children: [ | |
if (_showLoadingScroll) | |
Positioned.fill( | |
child: Container( | |
color: Colors.black, | |
child: const Center( | |
child: Column( | |
mainAxisSize: MainAxisSize.min, | |
children: [ | |
CircularProgressIndicator( | |
valueColor: | |
AlwaysStoppedAnimation<Color>(Colors.orange), | |
backgroundColor: Colors.white, | |
), | |
SizedBox(height: 12), | |
Text( | |
'Đang tải thêm để có thể scroll...', | |
style: TextStyle( | |
color: Colors.orange, | |
fontWeight: FontWeight.bold), | |
), | |
], | |
), | |
), | |
), | |
), | |
ListViewObserver.builderListViewObserver( | |
controller: observerController, | |
child: ListView.builder( | |
reverse: true, | |
controller: observerController.controller, | |
itemCount: journeyList.length, | |
padding: | |
const EdgeInsets.symmetric(horizontal: 16, vertical: 16), | |
itemBuilder: (context, weekIndex) { | |
final week = journeyList[weekIndex]; | |
return IntrinsicHeight( | |
child: Row( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
_buildTimelineConnector( | |
weekIndex, | |
journeyList.length, | |
week.week, | |
), | |
const SizedBox(width: 12), | |
Expanded(child: _buildWeekContent(week)), | |
], | |
), | |
); | |
}, | |
), | |
), | |
], | |
), | |
), | |
); | |
} | |
Widget _buildTimelineConnector(int index, int total, int label) { | |
return SizedBox( | |
width: 32, | |
child: Column( | |
children: [ | |
Expanded( | |
child: Container( | |
width: 2, | |
color: index == 0 ? Colors.transparent : Colors.grey.shade300, | |
), | |
), | |
Container( | |
width: 30, | |
height: 30, | |
decoration: const BoxDecoration( | |
shape: BoxShape.circle, | |
color: Colors.orange, | |
), | |
alignment: Alignment.center, | |
child: index == total - 1 | |
? const Icon(Icons.play_arrow, color: Colors.white, size: 18) | |
: Text('$label', style: const TextStyle(color: Colors.white)), | |
), | |
Expanded( | |
child: Container( | |
width: 2, | |
color: index == total - 1 | |
? Colors.transparent | |
: Colors.grey.shade300, | |
), | |
), | |
], | |
), | |
); | |
} | |
Widget _buildWeekContent(WeekData week) { | |
return Column( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
Text( | |
'Week #${week.week}', | |
style: const TextStyle( | |
fontSize: 18, | |
fontWeight: FontWeight.bold, | |
color: Colors.teal, | |
), | |
), | |
const SizedBox(height: 8), | |
...week.sessions.map(_buildSession).toList(), | |
], | |
); | |
} | |
Widget _buildSession(SessionData session) { | |
final isCurrent = session.status == 'current'; | |
final isDone = session.status == 'done'; | |
final textColor = isCurrent ? Colors.black : Colors.grey; | |
return Padding( | |
padding: const EdgeInsets.only(bottom: 12), | |
child: Column( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
Text( | |
'Session ${session.session}', | |
style: TextStyle( | |
fontSize: 16, | |
fontWeight: FontWeight.bold, | |
color: textColor, | |
), | |
), | |
const SizedBox(height: 4), | |
...session.sessionPractices | |
.map((p) => _buildExercise(p, isDone)) | |
.toList(), | |
const SizedBox(height: 4), | |
if (session.sessionType == 'assessment') | |
ElevatedButton( | |
onPressed: () => debugPrint('Navigate to Appointment'), | |
child: const Text('Book an Appointment'), | |
) | |
else if (isCurrent) ...[ | |
Row( | |
children: [ | |
TextButton( | |
onPressed: _confirmMarkAsDone, | |
child: const Text('Mark as done', | |
style: TextStyle(color: Colors.orange)), | |
), | |
const SizedBox(width: 4), | |
ElevatedButton( | |
onPressed: () => | |
debugPrint('Go to Start Session or Device Setting'), | |
child: const Text('Start'), | |
), | |
], | |
) | |
], | |
], | |
), | |
); | |
} | |
Widget _buildExercise(Practice practice, bool isDone) { | |
final label = | |
'${practice.side} ${practice.subGroup?.name ?? practice.muscleGroup.name}'; | |
return Padding( | |
padding: const EdgeInsets.symmetric(vertical: 2.0), | |
child: Row( | |
mainAxisAlignment: MainAxisAlignment.spaceBetween, | |
children: [ | |
Text(label, | |
style: TextStyle(color: isDone ? Colors.grey : Colors.black)), | |
if (isDone && practice.completedDate != null) | |
Text( | |
DateFormat('dd/MM/yyyy').format(practice.completedDate!), | |
style: const TextStyle(color: Colors.grey), | |
), | |
], | |
), | |
); | |
} | |
void _confirmMarkAsDone() { | |
showDialog( | |
context: context, | |
builder: (ctx) => AlertDialog( | |
title: const Text('Mark as Done'), | |
content: | |
const Text('Are you sure you want to mark this session as done?'), | |
actions: [ | |
TextButton( | |
onPressed: () => Navigator.of(ctx).pop(), | |
child: const Text('Cancel'), | |
), | |
ElevatedButton( | |
onPressed: () => Navigator.of(ctx).pop(), | |
child: const Text('Yes'), | |
), | |
], | |
), | |
); | |
} | |
} | |
// ---------------------- MOCK DATA ---------------------- | |
class WeekData { | |
final int week; | |
final List<SessionData> sessions; | |
WeekData({required this.week, required this.sessions}); | |
} | |
class SessionData { | |
final int session; | |
final String status; | |
final String sessionType; | |
final List<Practice> sessionPractices; | |
SessionData({ | |
required this.session, | |
required this.status, | |
required this.sessionType, | |
required this.sessionPractices, | |
}); | |
} | |
class Practice { | |
final int practiceId; | |
final String side; | |
final String status; | |
final DateTime? completedDate; | |
final SubGroup? subGroup; | |
final MuscleGroup muscleGroup; | |
Practice({ | |
required this.practiceId, | |
required this.side, | |
required this.status, | |
required this.completedDate, | |
this.subGroup, | |
required this.muscleGroup, | |
}); | |
} | |
class SubGroup { | |
final int muscleGroupId; | |
final String name; | |
SubGroup({required this.muscleGroupId, required this.name}); | |
} | |
class MuscleGroup { | |
final int muscleGroupId; | |
final String name; | |
MuscleGroup({required this.muscleGroupId, required this.name}); | |
} | |
const activeIndex = 40; | |
final List<WeekData> mockData = List.generate(100, (weekIndex) { | |
final int week = weekIndex + 1; | |
final String status = | |
(week == activeIndex) ? 'current' : (week < 20 ? 'done' : 'future'); | |
final String sessionType = (week % 4 == 0) ? 'assessment' : 'program'; | |
return WeekData( | |
week: week, | |
sessions: [ | |
SessionData( | |
session: 1, | |
status: status, | |
sessionType: sessionType, | |
sessionPractices: [ | |
Practice( | |
practiceId: week * 2 - 1, | |
side: 'left', | |
status: status == 'done' ? 'done' : 'pending', | |
completedDate: status == 'done' | |
? DateTime.now().subtract(Duration(days: week)) | |
: null, | |
subGroup: (week % 3 == 0) | |
? SubGroup( | |
muscleGroupId: 140 + (week % 5), | |
name: 'SubGroup ${week % 5}') | |
: null, | |
muscleGroup: MuscleGroup( | |
muscleGroupId: 150 + (week % 6), | |
name: 'MuscleGroup ${week % 6}'), | |
), | |
Practice( | |
practiceId: week * 2, | |
side: 'right', | |
status: status == 'done' ? 'done' : 'pending', | |
completedDate: status == 'done' | |
? DateTime.now().subtract(Duration(days: week)) | |
: null, | |
subGroup: null, | |
muscleGroup: MuscleGroup( | |
muscleGroupId: 150 + (week % 6), | |
name: 'MuscleGroup ${week % 6}'), | |
), | |
], | |
), | |
], | |
); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment