Skip to content

Instantly share code, notes, and snippets.

@hungify
Last active April 18, 2025 00:29
Show Gist options
  • Save hungify/af1547c6d18c6a5f78a9587e7e62d8a7 to your computer and use it in GitHub Desktop.
Save hungify/af1547c6d18c6a5f78a9587e7e62d8a7 to your computer and use it in GitHub Desktop.
Infinite Scroll to Top In Flutter
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