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:
@@ -13,6 +13,11 @@ import 'core/theme_manager.dart';
|
|||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await ThemeManager.init(); // load saved theme preference
|
await ThemeManager.init(); // load saved theme preference
|
||||||
|
|
||||||
|
// Increase image cache for smoother scrolling and faster re-renders
|
||||||
|
PaintingBinding.instance.imageCache.maximumSize = 500;
|
||||||
|
PaintingBinding.instance.imageCache.maximumSizeBytes = 200 * 1024 * 1024; // 200 MB
|
||||||
|
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1357,7 +1357,7 @@ class _ContributeScreenState extends State<ContributeScreen>
|
|||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Segmented Tabs (4 tabs)
|
// Segmented Tabs (4 tabs)
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
static const Curve _bouncyCurve = Cubic(0.37, 1.95, 0.66, 0.56);
|
static const Curve _bouncyCurve = Curves.easeInOutCubic;
|
||||||
|
|
||||||
static const List<IconData> _tabIcons = [
|
static const List<IconData> _tabIcons = [
|
||||||
Icons.edit_outlined,
|
Icons.edit_outlined,
|
||||||
@@ -1376,7 +1376,8 @@ class _ContributeScreenState extends State<ContributeScreen>
|
|||||||
const double padding = 5.0;
|
const double padding = 5.0;
|
||||||
final tabWidth = (constraints.maxWidth - padding * 2) / tabs.length;
|
final tabWidth = (constraints.maxWidth - padding * 2) / tabs.length;
|
||||||
|
|
||||||
return ClipRRect(
|
return RepaintBoundary(
|
||||||
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 52,
|
height: 52,
|
||||||
@@ -1389,7 +1390,7 @@ class _ContributeScreenState extends State<ContributeScreen>
|
|||||||
children: [
|
children: [
|
||||||
// Sliding glider
|
// Sliding glider
|
||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
duration: const Duration(milliseconds: 450),
|
duration: const Duration(milliseconds: 280),
|
||||||
curve: _bouncyCurve,
|
curve: _bouncyCurve,
|
||||||
left: padding + _activeTab * tabWidth,
|
left: padding + _activeTab * tabWidth,
|
||||||
top: padding,
|
top: padding,
|
||||||
@@ -1444,7 +1445,7 @@ class _ContributeScreenState extends State<ContributeScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
)); // RepaintBoundary
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
@@ -248,12 +249,16 @@ class _HomeDesktopScreenState extends State<HomeDesktopScreen> with SingleTicker
|
|||||||
|
|
||||||
double next = cur + delta;
|
double next = cur + delta;
|
||||||
if (next >= max) {
|
if (next >= max) {
|
||||||
// wrap back by half (seamless loop)
|
// wrap back by half (seamless loop — instant jump is intentional)
|
||||||
final wrapped = next - half;
|
final wrapped = next - half;
|
||||||
_marqueeController.jumpTo(wrapped.clamp(0.0, max));
|
_marqueeController.jumpTo(wrapped.clamp(0.0, max));
|
||||||
} else {
|
} else {
|
||||||
// small incremental jump gives smooth appearance
|
// smooth incremental scroll synced to ticker duration
|
||||||
_marqueeController.jumpTo(next);
|
_marqueeController.animateTo(
|
||||||
|
next,
|
||||||
|
duration: _marqueeTick,
|
||||||
|
curve: Curves.linear,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -769,7 +774,16 @@ class _HomeDesktopScreenState extends State<HomeDesktopScreen> with SingleTicker
|
|||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: img != null ? Image.network(img, width: 64, height: 64, fit: BoxFit.cover) : Container(width: 64, height: 64, color: Theme.of(context).dividerColor),
|
child: img != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: img,
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Container(width: 64, height: 64, color: Theme.of(context).dividerColor),
|
||||||
|
errorWidget: (_, __, ___) => Container(width: 64, height: 64, color: Theme.of(context).dividerColor, child: const Icon(Icons.event, size: 24, color: Colors.grey)),
|
||||||
|
)
|
||||||
|
: Container(width: 64, height: 64, color: Theme.of(context).dividerColor),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -817,7 +831,14 @@ class _HomeDesktopScreenState extends State<HomeDesktopScreen> with SingleTicker
|
|||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(cardRadius)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(cardRadius)),
|
||||||
child: img != null
|
child: img != null
|
||||||
? Image.network(img, width: double.infinity, height: imageHeight, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(height: imageHeight, color: theme.dividerColor))
|
? CachedNetworkImage(
|
||||||
|
imageUrl: img,
|
||||||
|
width: double.infinity,
|
||||||
|
height: imageHeight,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Container(height: imageHeight, color: theme.dividerColor),
|
||||||
|
errorWidget: (_, __, ___) => Container(height: imageHeight, color: theme.dividerColor),
|
||||||
|
)
|
||||||
: Container(height: imageHeight, width: double.infinity, color: theme.dividerColor),
|
: Container(height: imageHeight, width: double.infinity, color: theme.dividerColor),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -921,7 +942,14 @@ class _HomeDesktopScreenState extends State<HomeDesktopScreen> with SingleTicker
|
|||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(cardRadius)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(cardRadius)),
|
||||||
child: img != null
|
child: img != null
|
||||||
? Image.network(img, width: width, height: imageHeight, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(height: imageHeight, color: theme.dividerColor))
|
? CachedNetworkImage(
|
||||||
|
imageUrl: img,
|
||||||
|
width: width,
|
||||||
|
height: imageHeight,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Container(height: imageHeight, color: theme.dividerColor),
|
||||||
|
errorWidget: (_, __, ___) => Container(height: imageHeight, color: theme.dividerColor),
|
||||||
|
)
|
||||||
: Container(height: imageHeight, width: width, color: theme.dividerColor),
|
: Container(height: imageHeight, width: width, color: theme.dividerColor),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -41,12 +41,13 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
|
|
||||||
// Hero carousel
|
// Hero carousel
|
||||||
final PageController _heroPageController = PageController();
|
final PageController _heroPageController = PageController();
|
||||||
int _heroCurrentPage = 0;
|
late final ValueNotifier<int> _heroPageNotifier;
|
||||||
Timer? _autoScrollTimer;
|
Timer? _autoScrollTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_heroPageNotifier = ValueNotifier(0);
|
||||||
_loadUserDataAndEvents();
|
_loadUserDataAndEvents();
|
||||||
_startAutoScroll();
|
_startAutoScroll();
|
||||||
}
|
}
|
||||||
@@ -55,6 +56,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_autoScrollTimer?.cancel();
|
_autoScrollTimer?.cancel();
|
||||||
_heroPageController.dispose();
|
_heroPageController.dispose();
|
||||||
|
_heroPageNotifier.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
_autoScrollTimer?.cancel();
|
_autoScrollTimer?.cancel();
|
||||||
_autoScrollTimer = Timer.periodic(const Duration(seconds: 3), (timer) {
|
_autoScrollTimer = Timer.periodic(const Duration(seconds: 3), (timer) {
|
||||||
if (_heroEvents.isEmpty) return;
|
if (_heroEvents.isEmpty) return;
|
||||||
final nextPage = (_heroCurrentPage + 1) % _heroEvents.length;
|
final nextPage = (_heroPageNotifier.value + 1) % _heroEvents.length;
|
||||||
if (_heroPageController.hasClients) {
|
if (_heroPageController.hasClients) {
|
||||||
_heroPageController.animateToPage(
|
_heroPageController.animateToPage(
|
||||||
nextPage,
|
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))),
|
child: Center(child: Text(query.isEmpty ? 'Type to search events' : 'No events found', style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor))),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ListView.separated(
|
ConstrainedBox(
|
||||||
shrinkWrap: true,
|
constraints: const BoxConstraints(maxHeight: 400),
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
child: ListView.separated(
|
||||||
|
shrinkWrap: false,
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
itemBuilder: (ctx, idx) {
|
itemBuilder: (ctx, idx) {
|
||||||
final ev = results[idx];
|
final ev = results[idx];
|
||||||
final img = (ev.thumbImg != null && ev.thumbImg!.isNotEmpty) ? ev.thumbImg! : (ev.images.isNotEmpty ? ev.images.first.image : null);
|
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(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
|
||||||
leading: img != null && img.isNotEmpty
|
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)),
|
: 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),
|
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)),
|
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),
|
separatorBuilder: (_, __) => Divider(color: theme.dividerColor),
|
||||||
itemCount: results.length,
|
itemCount: results.length,
|
||||||
),
|
)), // ConstrainedBox
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -371,16 +385,19 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
// IndexedStack keeps each tab alive and preserves state.
|
// IndexedStack keeps each tab alive and preserves state.
|
||||||
|
// RepaintBoundary isolates each tab so inactive tabs don't trigger repaints.
|
||||||
IndexedStack(
|
IndexedStack(
|
||||||
index: _selectedIndex,
|
index: _selectedIndex,
|
||||||
children: [
|
children: [
|
||||||
_buildHomeContent(), // index 0
|
RepaintBoundary(child: _buildHomeContent()), // index 0
|
||||||
const CalendarScreen(), // index 1
|
const RepaintBoundary(child: CalendarScreen()), // index 1
|
||||||
ChangeNotifierProvider(
|
RepaintBoundary(
|
||||||
|
child: ChangeNotifierProvider(
|
||||||
create: (_) => GamificationProvider(),
|
create: (_) => GamificationProvider(),
|
||||||
child: const ContributeScreen(),
|
child: const ContributeScreen(),
|
||||||
|
),
|
||||||
), // index 2 (full page, scrollable)
|
), // 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,
|
height: 300,
|
||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
controller: _heroPageController,
|
controller: _heroPageController,
|
||||||
onPageChanged: (page) => setState(() => _heroCurrentPage = page),
|
onPageChanged: (page) => _heroPageNotifier.value = page,
|
||||||
itemCount: _heroEvents.length,
|
itemCount: _heroEvents.length,
|
||||||
itemBuilder: (context, index) => _buildHeroEventImage(_heroEvents[index]),
|
itemBuilder: (context, index) => _buildHeroEventImage(_heroEvents[index]),
|
||||||
),
|
),
|
||||||
@@ -1134,6 +1151,9 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCarouselDots() {
|
Widget _buildCarouselDots() {
|
||||||
|
return ValueListenableBuilder<int>(
|
||||||
|
valueListenable: _heroPageNotifier,
|
||||||
|
builder: (context, currentPage, _) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 12,
|
height: 12,
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -1141,7 +1161,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
children: List.generate(
|
children: List.generate(
|
||||||
_heroEvents.isEmpty ? 5 : _heroEvents.length,
|
_heroEvents.isEmpty ? 5 : _heroEvents.length,
|
||||||
(i) {
|
(i) {
|
||||||
final isActive = i == _heroCurrentPage;
|
final isActive = i == currentPage;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (_heroPageController.hasClients) {
|
if (_heroPageController.hasClients) {
|
||||||
@@ -1163,6 +1183,8 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a hero image card with the image only (rounded),
|
/// Build a hero image card with the image only (rounded),
|
||||||
|
|||||||
Reference in New Issue
Block a user