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:
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user