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:
@@ -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)),
|
||||||
|
|||||||
@@ -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(
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
color: Colors.white,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(14),
|
color: Colors.white,
|
||||||
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 2))],
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 2))],
|
||||||
|
),
|
||||||
|
child: Column(children: children),
|
||||||
),
|
),
|
||||||
child: Column(children: children),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// ── Ongoing Events ──
|
||||||
_buildEventSections(context),
|
if (_ongoingEvents.isNotEmpty) ...[
|
||||||
|
SliverToBoxAdapter(child: sectionTitle('Ongoing Events')),
|
||||||
const SizedBox(height: 32),
|
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)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user