EnakPromo: halaman promo dan detail promo

- Banner promo unggulan, info cara dapat, filter per tipe hadiah, daftar
  promo berlangsung dan segera hadir
- Detail promo: hadiah, periode, outlet, cara dapat, S&K, CTA ke Reward
  Saya atau daftar outlet
- Data contoh mengikuti bentuk campaign backend (POINTS/COINS/REWARD)
- Filter chip Reward Saya dipindah ke komponen bersama FilterBarDelegate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-10-01 02:17:54 +07:00
co-authored by Claude Opus 5.5
parent bc751246d8
commit c35d0eedbe
12 changed files with 1685 additions and 358 deletions
@@ -0,0 +1,359 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../../../common/theme/theme.dart';
import '../../../../../sample/promo_sample_data.dart';
import '../../../../components/button/button.dart';
import '../../../../router/app_router.gr.dart';
import '../../promo_ui.dart';
@RoutePage()
class PromoDetailPage extends StatelessWidget {
const PromoDetailPage({super.key, required this.promo});
final Promo promo;
@override
Widget build(BuildContext context) {
final isReward = promo.type == PromoType.reward;
return Scaffold(
backgroundColor: AppColor.background,
appBar: AppBar(title: const Text('Detail Promo')),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_buildHero(),
if (isPromoUpcoming(promo)) ...[
const SizedBox(height: 12),
_buildUpcomingNote(),
],
const SizedBox(height: 12),
_Section(
child: Column(
children: [
_InfoRow(
icon: Icons.card_giftcard_rounded,
label: 'Hadiah',
value: promo.rewardLabel,
),
const Divider(height: 20, color: AppColor.borderLight),
_InfoRow(
icon: Icons.calendar_month_rounded,
label: 'Periode',
value: promoPeriodLabel(promo),
trailing: PromoTimeBadge(promo: promo),
),
const Divider(height: 20, color: AppColor.borderLight),
_InfoRow(
icon: Icons.storefront_outlined,
label: 'Berlaku di',
value: promoOutletLabel(promo),
),
],
),
),
const SizedBox(height: 12),
_Section(
title: 'Tentang promo',
child: Text(
promo.description,
style: AppStyle.md.copyWith(
color: AppColor.textSecondary,
height: 1.5,
),
),
),
const SizedBox(height: 12),
_Section(
title: 'Cara dapat',
child: Column(
children: [
for (var i = 0; i < promo.steps.length; i++)
_StepRow(number: i + 1, text: promo.steps[i]),
],
),
),
if (promo.terms.isNotEmpty) ...[
const SizedBox(height: 12),
_Section(
title: 'Syarat & Ketentuan',
child: Column(
children: [
for (final term in promo.terms) _BulletRow(text: term),
],
),
),
],
],
),
bottomNavigationBar: Container(
decoration: const BoxDecoration(
color: AppColor.white,
border: Border(top: BorderSide(color: AppColor.borderLight)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: AppElevatedButton(
title: isReward ? 'Lihat Reward Saya' : 'Cari Outlet',
onPressed: () => context.router.push(
isReward ? const RewardRoute() : const OutletRoute(),
),
),
),
),
),
);
}
Widget _buildHero() {
final gradient = promo.type.gradient;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: gradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: gradient.last.withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(20),
),
child: Text(
promo.rewardLabel,
style: AppStyle.xs.copyWith(
color: promo.type.color,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(height: 10),
Text(
promo.title,
style: AppStyle.h6.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w800,
height: 1.2,
),
),
const SizedBox(height: 6),
Text(
promo.subtitle,
style: AppStyle.sm.copyWith(
color: AppColor.textWhite.withOpacity(0.9),
),
),
],
),
),
const SizedBox(width: 12),
PromoTypeIcon(type: promo.type, size: 80),
],
),
);
}
Widget _buildUpcomingNote() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColor.info.withOpacity(0.08),
borderRadius: BorderRadius.circular(AppValue.borderRadius),
),
child: Row(
children: [
const Icon(Icons.info_outline, size: 20, color: AppColor.info),
const SizedBox(width: 10),
Expanded(
child: Text(
'Promo ini mulai ${formatPromoDate(promo.startDate)}. Belanja '
'sebelum tanggal itu belum mendapat bonus.',
style: AppStyle.sm.copyWith(color: AppColor.textPrimary),
),
),
],
),
);
}
}
class _Section extends StatelessWidget {
const _Section({this.title, required this.child});
final String? title;
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null) ...[
Text(
title!,
style: AppStyle.md.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 12),
],
child,
],
),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({
required this.icon,
required this.label,
required this.value,
this.trailing,
});
final IconData icon;
final String label;
final String value;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColor.background,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 18, color: AppColor.textSecondary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: AppStyle.xs.copyWith(color: AppColor.textSecondary),
),
const SizedBox(height: 2),
Text(
value,
style: AppStyle.md.copyWith(fontWeight: FontWeight.w600),
),
],
),
),
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
],
);
}
}
class _StepRow extends StatelessWidget {
const _StepRow({required this.number, required this.text});
final int number;
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 22,
height: 22,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Text(
'$number',
style: AppStyle.xs.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(width: 10),
Expanded(child: Text(text, style: AppStyle.md.copyWith(height: 1.4))),
],
),
);
}
}
class _BulletRow extends StatelessWidget {
const _BulletRow({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 7),
child: Container(
width: 5,
height: 5,
decoration: const BoxDecoration(
color: AppColor.textSecondary,
shape: BoxShape.circle,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
height: 1.5,
),
),
),
],
),
);
}
}
@@ -0,0 +1,250 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../common/theme/theme.dart';
import '../../../sample/promo_sample_data.dart';
import '../../components/delegate/filter_bar_delegate.dart';
import '../../router/app_router.gr.dart';
import 'promo_ui.dart';
import 'widgets/promo_banner.dart';
import 'widgets/promo_card.dart';
enum _PromoFilter {
all('Semua'),
point('EnakPoint'),
coin('EnakCoin'),
reward('Hadiah');
const _PromoFilter(this.label);
final String label;
bool matches(Promo promo) => switch (this) {
_PromoFilter.all => true,
_PromoFilter.point => promo.type == PromoType.point,
_PromoFilter.coin => promo.type == PromoType.coin,
_PromoFilter.reward => promo.type == PromoType.reward,
};
}
@RoutePage()
class PromoPage extends StatefulWidget {
const PromoPage({super.key});
@override
State<PromoPage> createState() => _PromoPageState();
}
class _PromoPageState extends State<PromoPage> {
final PageController _bannerController = PageController(
viewportFraction: 0.9,
);
int _bannerIndex = 0;
_PromoFilter _filter = _PromoFilter.all;
/// Promo yang belum berakhir, paling cepat berakhir di atas.
final List<Promo> _promos =
promoSamples.where((p) => p.endDate.isAfter(DateTime.now())).toList()
..sort((a, b) => a.endDate.compareTo(b.endDate));
List<Promo> get _featured =>
_promos.where((p) => p.isFeatured && !isPromoUpcoming(p)).toList();
int _countOf(_PromoFilter filter) => _promos.where(filter.matches).length;
void _open(Promo promo) =>
context.router.push(PromoDetailRoute(promo: promo));
@override
void dispose() {
_bannerController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final visible = _promos.where(_filter.matches).toList();
final ongoing = visible.where((p) => !isPromoUpcoming(p)).toList();
final upcoming = visible.where(isPromoUpcoming).toList();
final featured = _featured;
return Scaffold(
backgroundColor: AppColor.background,
appBar: AppBar(title: const Text('EnakPromo')),
body: CustomScrollView(
slivers: [
if (featured.isNotEmpty)
SliverToBoxAdapter(child: _buildBanners(featured)),
SliverToBoxAdapter(child: _buildHowItWorks()),
SliverPersistentHeader(
pinned: true,
delegate: FilterBarDelegate<_PromoFilter>(
values: _PromoFilter.values,
selected: _filter,
labelOf: (filter) => filter.label,
countOf: _countOf,
onSelected: (filter) => setState(() => _filter = filter),
),
),
if (visible.isEmpty)
SliverFillRemaining(hasScrollBody: false, child: _buildEmpty())
else ...[
if (ongoing.isNotEmpty)
..._buildSection('Sedang berlangsung', ongoing),
if (upcoming.isNotEmpty) ..._buildSection('Segera hadir', upcoming),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
],
),
);
}
Widget _buildBanners(List<Promo> featured) {
return Column(
children: [
const SizedBox(height: 16),
SizedBox(
height: 168,
child: PageView.builder(
controller: _bannerController,
itemCount: featured.length,
onPageChanged: (index) => setState(() => _bannerIndex = index),
itemBuilder: (context, index) {
final promo = featured[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: PromoBanner(promo: promo, onTap: () => _open(promo)),
);
},
),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(featured.length, (index) {
final isActive = index == _bannerIndex;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: isActive ? 18 : 6,
height: 6,
decoration: BoxDecoration(
color: isActive ? AppColor.primary : AppColor.border,
borderRadius: BorderRadius.circular(3),
),
);
}),
),
],
);
}
Widget _buildHowItWorks() {
return Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(AppValue.borderRadius),
border: Border.all(color: AppColor.borderLight),
),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.08),
shape: BoxShape.circle,
),
child: const Icon(
Icons.phone_iphone_rounded,
size: 18,
color: AppColor.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: 'Tanpa klaim. ',
style: AppStyle.sm.copyWith(fontWeight: FontWeight.w700),
),
const TextSpan(
text:
'Sebutkan nomor HP akunmu ke kasir saat bayar, bonus '
'promo masuk otomatis.',
),
],
),
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
),
],
),
);
}
List<Widget> _buildSection(String title, List<Promo> promos) {
return [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 10),
sliver: SliverToBoxAdapter(
child: Text(
title,
style: AppStyle.md.copyWith(fontWeight: FontWeight.w700),
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList.separated(
itemCount: promos.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final promo = promos[index];
return PromoCard(promo: promo, onTap: () => _open(promo));
},
),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
];
}
Widget _buildEmpty() {
return Padding(
padding: const EdgeInsets.fromLTRB(32, 32, 32, 64),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 96,
height: 96,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.06),
shape: BoxShape.circle,
),
child: const Text('🏷️', style: TextStyle(fontSize: 44)),
),
const SizedBox(height: 16),
Text(
_filter == _PromoFilter.all
? 'Belum ada promo'
: 'Belum ada promo ${_filter.label}',
textAlign: TextAlign.center,
style: AppStyle.lg.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
Text(
'Promo baru akan muncul di sini.',
textAlign: TextAlign.center,
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
],
),
);
}
}
+140
View File
@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../common/theme/theme.dart';
import '../../../domain/wallet/wallet.dart';
import '../../../sample/promo_sample_data.dart';
import '../../components/assets/assets.gen.dart';
import '../wallet/wallet_ui.dart';
/// Promo aktif dengan sisa hari segini dianggap segera berakhir.
const int promoEndingSoonDays = 3;
extension PromoTypeUi on PromoType {
String get label => switch (this) {
PromoType.point => 'EnakPoint',
PromoType.coin => 'EnakCoin',
PromoType.reward => 'Hadiah',
};
/// Warna aksen untuk teks dan latar tipis; cukup gelap di atas putih.
Color get color => switch (this) {
PromoType.point => AppColor.primary,
PromoType.coin => const Color(0xFFC26A00),
PromoType.reward => AppColor.secondaryDark,
};
/// Latar banner; teks putih di atasnya.
List<Color> get gradient => switch (this) {
PromoType.point => AppColor.primaryGradient,
PromoType.coin => const [Color(0xFFF59E0B), Color(0xFFC2410C)],
PromoType.reward => const [Color(0xFF43A047), Color(0xFF1B5E20)],
};
}
bool isPromoUpcoming(Promo promo) => promo.startDate.isAfter(DateTime.now());
/// Sisa hari kalender sampai [Promo.endDate]; 0 berarti berakhir hari ini.
int promoDaysLeft(Promo promo) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final end = DateTime(
promo.endDate.year,
promo.endDate.month,
promo.endDate.day,
);
return end.difference(today).inDays;
}
bool isPromoEndingSoon(Promo promo) =>
!isPromoUpcoming(promo) && promoDaysLeft(promo) <= promoEndingSoonDays;
/// "12 Okt".
String formatPromoDate(DateTime date) =>
DateFormat('d MMM', 'id_ID').format(date);
/// "1 Okt – 25 Okt 2026".
String promoPeriodLabel(Promo promo) =>
'${formatPromoDate(promo.startDate)} – '
'${DateFormat('d MMM yyyy', 'id_ID').format(promo.endDate)}';
String promoOutletLabel(Promo promo) =>
promo.outlets.isEmpty ? 'Semua outlet' : promo.outlets.join(', ');
/// "Mulai 4 Okt" / "Berakhir 2 hari lagi" / "s/d 25 Okt".
String promoTimeLabel(Promo promo) {
if (isPromoUpcoming(promo)) {
return 'Mulai ${formatPromoDate(promo.startDate)}';
}
final daysLeft = promoDaysLeft(promo);
if (daysLeft <= 0) return 'Berakhir hari ini';
if (daysLeft <= promoEndingSoonDays) return 'Berakhir $daysLeft hari lagi';
return 's/d ${formatPromoDate(promo.endDate)}';
}
/// Ikon 3D sesuai hadiah promo.
class PromoTypeIcon extends StatelessWidget {
const PromoTypeIcon({super.key, required this.type, this.size = 32});
final PromoType type;
final double size;
@override
Widget build(BuildContext context) {
return switch (type) {
PromoType.point => WalletCurrencyIcon(
currency: WalletCurrency.point,
size: size,
),
PromoType.coin => WalletCurrencyIcon(
currency: WalletCurrency.coin,
size: size,
),
PromoType.reward => Assets.icons.enakrewardIcon.image(
width: size,
height: size,
fit: BoxFit.contain,
// Aset aslinya besar; decode seukuran tampilan saja.
cacheWidth: (size * MediaQuery.devicePixelRatioOf(context)).round(),
),
};
}
}
/// Label waktu promo: biru bila belum mulai, oranye bila segera berakhir.
class PromoTimeBadge extends StatelessWidget {
const PromoTimeBadge({super.key, required this.promo});
final Promo promo;
@override
Widget build(BuildContext context) {
final color = isPromoUpcoming(promo)
? AppColor.info
: isPromoEndingSoon(promo)
? AppColor.warning
: AppColor.textSecondary;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.schedule, size: 12, color: color),
const SizedBox(width: 4),
Text(
promoTimeLabel(promo),
style: AppStyle.xs.copyWith(
color: color,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import '../../../../common/theme/theme.dart';
import '../../../../sample/promo_sample_data.dart';
import '../promo_ui.dart';
/// Kartu besar untuk promo unggulan di bagian atas EnakPromo.
class PromoBanner extends StatelessWidget {
const PromoBanner({super.key, required this.promo, required this.onTap});
final Promo promo;
final VoidCallback onTap;
static const double _iconSize = 96;
@override
Widget build(BuildContext context) {
final gradient = promo.type.gradient;
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: gradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: gradient.last.withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Stack(
children: [
// Lingkaran dekoratif di belakang ikon.
Positioned(
right: -36,
top: -36,
child: _Circle(size: 150, opacity: 0.1),
),
Positioned(
right: 40,
bottom: -48,
child: _Circle(size: 100, opacity: 0.08),
),
Positioned(
right: 14,
bottom: 14,
child: PromoTypeIcon(type: promo.type, size: _iconSize),
),
Padding(
padding: const EdgeInsets.fromLTRB(
18,
16,
_iconSize + 20,
16,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Pill(text: promo.rewardLabel, color: promo.type.color),
const SizedBox(height: 8),
Text(
promo.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppStyle.xl.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w800,
height: 1.2,
),
),
const SizedBox(height: 4),
Text(
promo.subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppStyle.sm.copyWith(
color: AppColor.textWhite.withOpacity(0.9),
),
),
const Spacer(),
Row(
children: [
Icon(
Icons.schedule,
size: 14,
color: AppColor.textWhite.withOpacity(0.9),
),
const SizedBox(width: 4),
Text(
promoTimeLabel(promo),
style: AppStyle.xs.copyWith(
color: AppColor.textWhite.withOpacity(0.9),
fontWeight: FontWeight.w700,
),
),
],
),
],
),
),
],
),
),
),
),
);
}
}
class _Pill extends StatelessWidget {
const _Pill({required this.text, required this.color});
final String text;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(20),
),
child: Text(
text,
style: AppStyle.xs.copyWith(color: color, fontWeight: FontWeight.w800),
),
);
}
}
class _Circle extends StatelessWidget {
const _Circle({required this.size, required this.opacity});
final double size;
final double opacity;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: AppColor.white.withOpacity(opacity),
shape: BoxShape.circle,
),
);
}
}
@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import '../../../../common/theme/theme.dart';
import '../../../../sample/promo_sample_data.dart';
import '../promo_ui.dart';
class PromoCard extends StatelessWidget {
const PromoCard({super.key, required this.promo, required this.onTap});
final Promo promo;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final color = promo.type.color;
return Material(
color: AppColor.white,
borderRadius: BorderRadius.circular(16),
shadowColor: Colors.black.withOpacity(0.25),
elevation: 1,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 56,
height: 56,
alignment: Alignment.center,
decoration: BoxDecoration(
color: color.withOpacity(0.08),
borderRadius: BorderRadius.circular(14),
),
child: PromoTypeIcon(type: promo.type, size: 34),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
promo.rewardLabel,
style: AppStyle.xs.copyWith(
color: color,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(height: 6),
Text(
promo.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppStyle.md.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 2),
Text(
promo.subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
),
const SizedBox(height: 10),
// Wrap: di layar sempit label waktu turun ke baris baru
// supaya nama outlet tidak terpotong.
Wrap(
spacing: 8,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.storefront_outlined,
size: 14,
color: AppColor.textSecondary,
),
const SizedBox(width: 4),
Flexible(
child: Text(
promoOutletLabel(promo),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
],
),
PromoTimeBadge(promo: promo),
],
),
],
),
),
],
),
),
),
);
}
}