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;
|
bool _loading = true;
|
||||||
|
|
||||||
// Hero carousel
|
// Hero carousel
|
||||||
final PageController _heroPageController = PageController();
|
final PageController _heroPageController = PageController(viewportFraction: 0.88);
|
||||||
late final ValueNotifier<int> _heroPageNotifier;
|
late final ValueNotifier<int> _heroPageNotifier;
|
||||||
Timer? _autoScrollTimer;
|
Timer? _autoScrollTimer;
|
||||||
|
|
||||||
@@ -1131,12 +1131,31 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
: Column(
|
: Column(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 300,
|
height: 320,
|
||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
controller: _heroPageController,
|
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,
|
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),
|
const SizedBox(height: 16),
|
||||||
@@ -1197,58 +1216,147 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
img = event.images.first.image;
|
img = event.images.first.image;
|
||||||
}
|
}
|
||||||
|
|
||||||
final radius = 24.0;
|
const double radius = 24.0;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (event.id != null) {
|
if (event.id != null) {
|
||||||
Navigator.push(context, MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: event.id)));
|
Navigator.push(context,
|
||||||
|
MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: event.id)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Column(
|
child: ClipRRect(
|
||||||
children: [
|
borderRadius: BorderRadius.circular(radius),
|
||||||
// Image only (no text overlay)
|
child: Stack(
|
||||||
Expanded(
|
fit: StackFit.expand,
|
||||||
child: ClipRRect(
|
children: [
|
||||||
borderRadius: BorderRadius.circular(radius),
|
// ── Layer 0: Event image (full-bleed) ──
|
||||||
child: SizedBox(
|
img != null && img.isNotEmpty
|
||||||
width: double.infinity,
|
? CachedNetworkImage(
|
||||||
child: img != null && img.isNotEmpty
|
imageUrl: img,
|
||||||
? CachedNetworkImage(
|
fit: BoxFit.cover,
|
||||||
imageUrl: img,
|
placeholder: (_, __) => const _HeroShimmer(radius: radius),
|
||||||
fit: BoxFit.cover,
|
errorWidget: (_, __, ___) =>
|
||||||
placeholder: (_, __) => Container(
|
Container(decoration: AppDecoration.blueGradientRounded(radius)),
|
||||||
decoration: const BoxDecoration(color: Color(0xFF1A2A4A))),
|
)
|
||||||
errorWidget: (_, __, ___) =>
|
: Container(decoration: AppDecoration.blueGradientRounded(radius)),
|
||||||
Container(decoration: AppDecoration.blueGradientRounded(radius)),
|
|
||||||
)
|
// ── Layer 1: Bottom gradient overlay (text readability) ──
|
||||||
: Container(
|
Positioned.fill(
|
||||||
decoration: AppDecoration.blueGradientRounded(radius),
|
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
|
// ── Layer 2: FEATURED glassmorphism badge (top-left) ──
|
||||||
const SizedBox(height: 12),
|
Positioned(
|
||||||
Text(
|
top: 14,
|
||||||
event.title ?? event.name ?? '',
|
left: 14,
|
||||||
maxLines: 2,
|
child: ClipRRect(
|
||||||
overflow: TextOverflow.ellipsis,
|
borderRadius: BorderRadius.circular(20),
|
||||||
textAlign: TextAlign.center,
|
child: BackdropFilter(
|
||||||
style: const TextStyle(
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||||
color: Colors.white,
|
child: Container(
|
||||||
fontSize: 22,
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
fontWeight: FontWeight.bold,
|
decoration: BoxDecoration(
|
||||||
height: 1.2,
|
color: Colors.white.withOpacity(0.18),
|
||||||
shadows: [
|
borderRadius: BorderRadius.circular(20),
|
||||||
Shadow(color: Colors.black38, blurRadius: 6, offset: Offset(0, 2)),
|
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';
|
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