perf: fix Android lag, snapping animations & slow image loading

Fix 1: Replace overshooting Cubic(1.95) tab glider curve with
  Curves.easeInOutCubic; reduce duration 450ms → 280ms
Fix 2: Replace marquee jumpTo() with animateTo(linear) for fluid scroll
Fix 3: Replace Image.network with CachedNetworkImage in search results
Fix 4: Replace Image.network with CachedNetworkImage in desktop cards
Fix 5: Wrap IndexedStack children in RepaintBoundary to isolate
  repaints across tabs (Home/Calendar/Contribute/Profile)
Fix 6: Replace setState on PageView.onPageChanged with ValueNotifier
  so only the carousel dots widget rebuilds on swipe
Fix 7: Wrap animated tab glider in RepaintBoundary
Fix 8: Replace shrinkWrap:true ListView with ConstrainedBox(maxHeight)
  to eliminate O(n) layout pass in search results
Fix 9: Increase image cache to 200MB / 500 images in main()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 15:39:42 +05:30
parent 97245e01c4
commit 5b373e8694
4 changed files with 107 additions and 51 deletions

View File

@@ -41,12 +41,13 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
// Hero carousel
final PageController _heroPageController = PageController();
int _heroCurrentPage = 0;
late final ValueNotifier<int> _heroPageNotifier;
Timer? _autoScrollTimer;
@override
void initState() {
super.initState();
_heroPageNotifier = ValueNotifier(0);
_loadUserDataAndEvents();
_startAutoScroll();
}
@@ -55,6 +56,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
void dispose() {
_autoScrollTimer?.cancel();
_heroPageController.dispose();
_heroPageNotifier.dispose();
super.dispose();
}
@@ -62,7 +64,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
_autoScrollTimer?.cancel();
_autoScrollTimer = Timer.periodic(const Duration(seconds: 3), (timer) {
if (_heroEvents.isEmpty) return;
final nextPage = (_heroCurrentPage + 1) % _heroEvents.length;
final nextPage = (_heroPageNotifier.value + 1) % _heroEvents.length;
if (_heroPageController.hasClients) {
_heroPageController.animateToPage(
nextPage,
@@ -324,9 +326,11 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
child: Center(child: Text(query.isEmpty ? 'Type to search events' : 'No events found', style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor))),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: ListView.separated(
shrinkWrap: false,
physics: const ClampingScrollPhysics(),
itemBuilder: (ctx, idx) {
final ev = results[idx];
final img = (ev.thumbImg != null && ev.thumbImg!.isNotEmpty) ? ev.thumbImg! : (ev.images.isNotEmpty ? ev.images.first.image : null);
@@ -335,7 +339,17 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
leading: img != null && img.isNotEmpty
? ClipRRect(borderRadius: BorderRadius.circular(8), child: Image.network(img, width: 56, height: 56, fit: BoxFit.cover))
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: img,
width: 56,
height: 56,
fit: BoxFit.cover,
placeholder: (_, __) => Container(width: 56, height: 56, color: theme.dividerColor),
errorWidget: (_, __, ___) => Container(width: 56, height: 56, color: theme.dividerColor, child: Icon(Icons.event, color: theme.hintColor)),
),
)
: Container(width: 56, height: 56, decoration: BoxDecoration(color: theme.dividerColor, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.event, color: theme.hintColor)),
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyLarge),
subtitle: Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor)),
@@ -349,7 +363,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
},
separatorBuilder: (_, __) => Divider(color: theme.dividerColor),
itemCount: results.length,
),
)), // ConstrainedBox
],
),
),
@@ -371,16 +385,19 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
body: Stack(
children: [
// IndexedStack keeps each tab alive and preserves state.
// RepaintBoundary isolates each tab so inactive tabs don't trigger repaints.
IndexedStack(
index: _selectedIndex,
children: [
_buildHomeContent(), // index 0
const CalendarScreen(), // index 1
ChangeNotifierProvider(
create: (_) => GamificationProvider(),
child: const ContributeScreen(),
RepaintBoundary(child: _buildHomeContent()), // index 0
const RepaintBoundary(child: CalendarScreen()), // index 1
RepaintBoundary(
child: ChangeNotifierProvider(
create: (_) => GamificationProvider(),
child: const ContributeScreen(),
),
), // index 2 (full page, scrollable)
const ProfileScreen(), // index 3
const RepaintBoundary(child: ProfileScreen()), // index 3
],
),
@@ -1117,7 +1134,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
height: 300,
child: PageView.builder(
controller: _heroPageController,
onPageChanged: (page) => setState(() => _heroCurrentPage = page),
onPageChanged: (page) => _heroPageNotifier.value = page,
itemCount: _heroEvents.length,
itemBuilder: (context, index) => _buildHeroEventImage(_heroEvents[index]),
),
@@ -1134,34 +1151,39 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
}
Widget _buildCarouselDots() {
return SizedBox(
height: 12,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
_heroEvents.isEmpty ? 5 : _heroEvents.length,
(i) {
final isActive = i == _heroCurrentPage;
return GestureDetector(
onTap: () {
if (_heroPageController.hasClients) {
_heroPageController.animateToPage(i,
duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
}
return ValueListenableBuilder<int>(
valueListenable: _heroPageNotifier,
builder: (context, currentPage, _) {
return SizedBox(
height: 12,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
_heroEvents.isEmpty ? 5 : _heroEvents.length,
(i) {
final isActive = i == currentPage;
return GestureDetector(
onTap: () {
if (_heroPageController.hasClients) {
_heroPageController.animateToPage(i,
duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
}
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
width: isActive ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: isActive ? Colors.white : Colors.white.withOpacity(0.4),
borderRadius: BorderRadius.circular(4),
),
),
);
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
width: isActive ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: isActive ? Colors.white : Colors.white.withOpacity(0.4),
borderRadius: BorderRadius.circular(4),
),
),
);
},
),
),
),
),
);
},
);
}