perf: fix scroll lag on profile/contribute, unpin calendar gradient

profile_screen: SingleChildScrollView + Column eagerly built every event
card (all images, shadows, tiles) at once even when off-screen. Replaced
with CustomScrollView + SliverList so only visible tiles are built per
frame. Also switches to BouncingScrollPhysics for natural momentum.

contribute_screen: Each _formCard wrapped in RepaintBoundary so form
cards are isolated render layers — one card's repaint doesn't invalidate
its siblings. Added BouncingScrollPhysics to the form SingleChildScrollView.

calendar_screen: Blue gradient banner was Positioned(top:0) making it
sticky even as the user scrolled. Removed the fixed Positioned layer and
moved the gradient inside the CustomScrollView as the first sliver in a
Stack alongside the calendar card (which keeps its y=110 visual overlap).
Now the entire page — gradient, calendar, events — scrolls as one unit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 17:16:38 +05:30
parent 48f143399d
commit 9dcd5bae16
3 changed files with 118 additions and 41 deletions

View File

@@ -569,28 +569,11 @@ class _CalendarScreenState extends State<CalendarScreen> {
} }
// MOBILE layout // MOBILE layout
// Stack: extended gradient panel (below appbar) that visually extends behind the calendar.
return Scaffold( return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor, backgroundColor: theme.scaffoldBackgroundColor,
body: Stack( body: Stack(
children: [ children: [
// Extended blue gradient panel behind calendar (matches reference) // TOP APP BAR stays fixed (title + bell icon)
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
height: 260, // controls how much gradient shows behind calendar
decoration: AppDecoration.blueGradient.copyWith(
borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(30), bottomRight: Radius.circular(30)),
boxShadow: [const BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, 6))],
),
// leave child empty — title and bell are placed above
child: const SizedBox.shrink(),
),
),
// TOP APP BAR (title centered + notification at top-right) - unchanged placement
Positioned( Positioned(
top: 0, top: 0,
left: 0, left: 0,
@@ -637,15 +620,34 @@ class _CalendarScreenState extends State<CalendarScreen> {
), ),
), ),
// CONTENT: whole page scrolls as one — calendar + summary + events // CONTENT: gradient + calendar card scroll together as one unit
CustomScrollView( CustomScrollView(
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
slivers: [ slivers: [
// Space for app bar + gradient top // Gradient + calendar card in one scrollable Stack
const SliverToBoxAdapter(child: SizedBox(height: 110)), // Gradient scrolls away with content; app bar remains fixed above
SliverToBoxAdapter(
// Calendar card child: Stack(
SliverToBoxAdapter(child: _calendarCard(context)), children: [
// Blue gradient banner — scrolls with content
Container(
height: 260,
decoration: AppDecoration.blueGradient.copyWith(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(30),
bottomRight: Radius.circular(30),
),
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, 6))],
),
),
// Calendar card starts at y=110 (after app bar), overlapping gradient
Padding(
padding: const EdgeInsets.only(top: 110),
child: _calendarCard(context),
),
],
),
),
// Selected date summary // Selected date summary
SliverToBoxAdapter(child: _selectedDateSummary(context)), SliverToBoxAdapter(child: _selectedDateSummary(context)),

View File

@@ -1470,6 +1470,7 @@ class _ContributeScreenState extends State<ContributeScreen>
Widget _buildContributeTab(BuildContext context, GamificationProvider provider) { Widget _buildContributeTab(BuildContext context, GamificationProvider provider) {
final theme = Theme.of(context); final theme = Theme.of(context);
return SingleChildScrollView( return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 20, 16, 32), padding: const EdgeInsets.fromLTRB(16, 20, 16, 32),
child: Form( child: Form(
key: _formKey, key: _formKey,
@@ -1563,13 +1564,15 @@ class _ContributeScreenState extends State<ContributeScreen>
} }
Widget _formCard(List<Widget> children) { Widget _formCard(List<Widget> children) {
return Container( return RepaintBoundary(
child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 2))], boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 2))],
), ),
child: Column(children: children), child: Column(children: children),
),
); );
} }

View File

@@ -1017,15 +1017,26 @@ class _ProfileScreenState extends State<ProfileScreen>
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
const double headerHeight = 200.0; const double headerHeight = 200.0;
const double cardTopOffset = 130.0; // card starts overlapping into header const double cardTopOffset = 130.0;
Widget sectionTitle(String text) => Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 12),
child: Text(
text,
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600, fontSize: 18),
),
);
return Scaffold( return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor, backgroundColor: theme.scaffoldBackgroundColor,
body: SingleChildScrollView( // CustomScrollView: only visible event cards are built — no full-tree Column renders
child: Column( body: CustomScrollView(
children: [ physics: const BouncingScrollPhysics(),
// Header + Profile Card overlap using Stack slivers: [
Stack( // Header gradient + Profile card overlap (same visual as before)
SliverToBoxAdapter(
child: Stack(
children: [ children: [
_buildGradientHeader(context, headerHeight), _buildGradientHeader(context, headerHeight),
Padding( Padding(
@@ -1034,13 +1045,74 @@ class _ProfileScreenState extends State<ProfileScreen>
), ),
], ],
), ),
// Event sections
_buildEventSections(context),
const SizedBox(height: 32),
],
), ),
// ── Ongoing Events ──
if (_ongoingEvents.isNotEmpty) ...[
SliverToBoxAdapter(child: sectionTitle('Ongoing Events')),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 18),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(ctx, i) => _eventListTileFromModel(_ongoingEvents[i]),
childCount: _ongoingEvents.length,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
// ── Upcoming Events ──
SliverToBoxAdapter(child: sectionTitle('Upcoming Events')),
if (_loadingEvents)
const SliverToBoxAdapter(child: SizedBox.shrink())
else if (_upcomingEvents.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
child: Text('No upcoming events',
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.hintColor)),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 18),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(ctx, i) => _eventListTileFromModel(_upcomingEvents[i]),
childCount: _upcomingEvents.length,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
// ── Past Events ──
SliverToBoxAdapter(child: sectionTitle('Past Events')),
if (_loadingEvents)
const SliverToBoxAdapter(child: SizedBox.shrink())
else if (_pastEvents.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
child: Text('No past events',
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.hintColor)),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 18),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(ctx, i) => _eventListTileFromModel(_pastEvents[i], faded: true),
childCount: _pastEvents.length,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
), ),
); );
} }