feat: redesign hero carousel — overlay, peek, scale, shimmer, FEATURED

UI/UX Pro Max + Flutter Expert audit of the home screen hero section.

viewportFraction 0.88
  Adjacent cards peek 6% on each side — users see there is more content
  to swipe without any instruction. Most impactful single-line UX change.

Overlay card design
  Title and metadata (date + location) now live ON the image behind a
  dark gradient (transparent → black 78%) at the bottom 65% of the card.
  Previously the title was below the image in a split layout that wasted
  space and felt disconnected. Card height increased 300 → 320px.

FEATURED glassmorphism badge
  Top-left corner chip with BackdropFilter blur (sigmaX/Y 10) and a
  white-border container gives each card a premium editorial feel.

Scale animation (AnimatedBuilder per card)
  Active card scales to 1.0, adjacent cards to 0.94. The AnimatedBuilder
  is placed inside itemBuilder so only the visible card rebuilds on each
  scroll tick — not the PageView or any ancestor.

Auto-scroll resets on page change
  onPageChanged now calls _startAutoScroll() which cancels the previous
  timer and starts a fresh 3-second countdown. Users who swipe manually
  always get a full 3 seconds to read before auto-advance continues.

Shimmer loading placeholder (_HeroShimmer)
  New StatefulWidget added below HomeScreen — a LinearGradient scan-line
  animated at 1400ms repeat. Replaces the flat Color(0xFF1A2A4A) box that
  looked broken while images were loading.
This commit is contained in:
2026-03-19 12:03:13 +05:30
parent d74e637a59
commit 6d29b95118

View File

@@ -40,7 +40,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
bool _loading = true;
// Hero carousel
final PageController _heroPageController = PageController();
final PageController _heroPageController = PageController(viewportFraction: 0.88);
late final ValueNotifier<int> _heroPageNotifier;
Timer? _autoScrollTimer;
@@ -1131,12 +1131,31 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
: Column(
children: [
SizedBox(
height: 300,
height: 320,
child: PageView.builder(
controller: _heroPageController,
onPageChanged: (page) => _heroPageNotifier.value = page,
onPageChanged: (page) {
_heroPageNotifier.value = page;
// Reset 3-second countdown so user always gets full read time
_startAutoScroll();
},
itemCount: _heroEvents.length,
itemBuilder: (context, index) => _buildHeroEventImage(_heroEvents[index]),
itemBuilder: (context, index) {
// Scale animation: active card = 1.0, adjacent = 0.94
return AnimatedBuilder(
animation: _heroPageController,
builder: (context, child) {
double scale = index == _heroPageNotifier.value ? 1.0 : 0.94;
if (_heroPageController.position.haveDimensions) {
scale = (1.0 -
(_heroPageController.page! - index).abs() * 0.06)
.clamp(0.94, 1.0);
}
return Transform.scale(scale: scale, child: child);
},
child: _buildHeroEventImage(_heroEvents[index]),
);
},
),
),
const SizedBox(height: 16),
@@ -1197,58 +1216,147 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
img = event.images.first.image;
}
final radius = 24.0;
const double radius = 24.0;
return GestureDetector(
onTap: () {
if (event.id != null) {
Navigator.push(context, MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: event.id)));
Navigator.push(context,
MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: event.id)));
}
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
// Image only (no text overlay)
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: SizedBox(
width: double.infinity,
child: img != null && img.isNotEmpty
? CachedNetworkImage(
imageUrl: img,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
decoration: const BoxDecoration(color: Color(0xFF1A2A4A))),
errorWidget: (_, __, ___) =>
Container(decoration: AppDecoration.blueGradientRounded(radius)),
)
: Container(
decoration: AppDecoration.blueGradientRounded(radius),
),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: Stack(
fit: StackFit.expand,
children: [
// ── Layer 0: Event image (full-bleed) ──
img != null && img.isNotEmpty
? CachedNetworkImage(
imageUrl: img,
fit: BoxFit.cover,
placeholder: (_, __) => const _HeroShimmer(radius: radius),
errorWidget: (_, __, ___) =>
Container(decoration: AppDecoration.blueGradientRounded(radius)),
)
: Container(decoration: AppDecoration.blueGradientRounded(radius)),
// ── Layer 1: Bottom gradient overlay (text readability) ──
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.35, 1.0],
colors: [
Colors.transparent,
Colors.black.withOpacity(0.78),
],
),
),
),
),
),
// Title text outside the image
const SizedBox(height: 12),
Text(
event.title ?? event.name ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
height: 1.2,
shadows: [
Shadow(color: Colors.black38, blurRadius: 6, offset: Offset(0, 2)),
],
// ── Layer 2: FEATURED glassmorphism badge (top-left) ──
Positioned(
top: 14,
left: 14,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.18),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.28)),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.star_rounded, color: Colors.amber, size: 13),
SizedBox(width: 4),
Text(
'FEATURED',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w800,
letterSpacing: 0.6,
),
),
],
),
),
),
),
),
),
],
// ── Layer 3: Title + metadata (bottom overlay) ──
Positioned(
bottom: 18,
left: 16,
right: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
event.title ?? event.name ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w800,
height: 1.25,
shadows: [
Shadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
],
),
),
const SizedBox(height: 8),
Row(
children: [
if (event.startDate != null) ...[
const Icon(Icons.calendar_today_rounded,
color: Colors.white70, size: 12),
const SizedBox(width: 4),
Text(
event.startDate!,
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
fontWeight: FontWeight.w500),
),
const SizedBox(width: 10),
],
if (event.place != null && event.place!.isNotEmpty) ...[
const Icon(Icons.location_on_rounded,
color: Colors.white70, size: 12),
const SizedBox(width: 4),
Flexible(
child: Text(
event.place!,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
fontWeight: FontWeight.w500),
),
),
],
],
),
],
),
),
],
),
),
),
);
@@ -2067,3 +2175,57 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
return 'You';
}
}
/// Animated shimmer placeholder shown while a hero card image is loading.
/// Renders a blue-toned scan-line effect matching the app's colour palette.
class _HeroShimmer extends StatefulWidget {
final double radius;
const _HeroShimmer({required this.radius});
@override
State<_HeroShimmer> createState() => _HeroShimmerState();
}
class _HeroShimmerState extends State<_HeroShimmer>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
)..repeat();
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _ctrl,
builder: (_, __) {
final x = -1.5 + _ctrl.value * 3.0;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(widget.radius),
gradient: LinearGradient(
begin: Alignment(x - 1.0, 0),
end: Alignment(x, 0),
colors: const [
Color(0xFF1A2A4A),
Color(0xFF2D4580),
Color(0xFF1A2A4A),
],
),
),
);
},
);
}
}