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:
co-authored by
Claude Opus 5.5
parent
bc751246d8
commit
c35d0eedbe
@@ -0,0 +1,69 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../common/theme/theme.dart';
|
||||||
|
|
||||||
|
/// Chip filter berbentuk pil dengan jumlah item di sebelah label.
|
||||||
|
class AppFilterChip extends StatelessWidget {
|
||||||
|
const AppFilterChip({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.count,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final int count;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final foreground = isSelected ? AppColor.textWhite : AppColor.textPrimary;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: isSelected ? AppColor.primary : AppColor.white,
|
||||||
|
shape: StadiumBorder(
|
||||||
|
side: BorderSide(
|
||||||
|
color: isSelected ? AppColor.primary : AppColor.border,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
customBorder: const StadiumBorder(),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: AppStyle.sm.copyWith(
|
||||||
|
color: foreground,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? AppColor.white.withOpacity(0.2)
|
||||||
|
: AppColor.primary.withOpacity(0.08),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$count',
|
||||||
|
style: AppStyle.xs.copyWith(
|
||||||
|
color: isSelected ? AppColor.textWhite : AppColor.primary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../common/theme/theme.dart';
|
||||||
|
import '../chip/app_filter_chip.dart';
|
||||||
|
|
||||||
|
/// Baris [AppFilterChip] yang bisa di-scroll menyamping, untuk
|
||||||
|
/// [SliverPersistentHeader] yang menempel di atas daftar.
|
||||||
|
class FilterBarDelegate<T> extends SliverPersistentHeaderDelegate {
|
||||||
|
FilterBarDelegate({
|
||||||
|
required this.values,
|
||||||
|
required this.selected,
|
||||||
|
required this.labelOf,
|
||||||
|
required this.countOf,
|
||||||
|
required this.onSelected,
|
||||||
|
this.backgroundColor = AppColor.background,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<T> values;
|
||||||
|
final T selected;
|
||||||
|
final String Function(T value) labelOf;
|
||||||
|
final int Function(T value) countOf;
|
||||||
|
final ValueChanged<T> onSelected;
|
||||||
|
final Color backgroundColor;
|
||||||
|
|
||||||
|
static const double _height = 56;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get minExtent => _height;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get maxExtent => _height;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(
|
||||||
|
BuildContext context,
|
||||||
|
double shrinkOffset,
|
||||||
|
bool overlapsContent,
|
||||||
|
) {
|
||||||
|
return Container(
|
||||||
|
color: backgroundColor,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
itemCount: values.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final value = values[index];
|
||||||
|
return AppFilterChip(
|
||||||
|
label: labelOf(value),
|
||||||
|
count: countOf(value),
|
||||||
|
isSelected: value == selected,
|
||||||
|
onTap: () => onSelected(value),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRebuild(covariant FilterBarDelegate<T> oldDelegate) =>
|
||||||
|
oldDelegate.selected != selected || oldDelegate.values != values;
|
||||||
|
}
|
||||||
@@ -57,10 +57,7 @@ class HomeServiceSection extends StatelessWidget {
|
|||||||
_HomeMenu(
|
_HomeMenu(
|
||||||
label: 'EnakPromo',
|
label: 'EnakPromo',
|
||||||
icon: Assets.icons.enakpromoIcon,
|
icon: Assets.icons.enakpromoIcon,
|
||||||
route: ComingSoonRoute(
|
route: const PromoRoute(),
|
||||||
title: 'EnakPromo',
|
|
||||||
iconPath: Assets.icons.enakpromoIcon.path,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
_HomeMenu(
|
_HomeMenu(
|
||||||
label: 'EnakShop',
|
label: 'EnakShop',
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../../common/theme/theme.dart';
|
import '../../../common/theme/theme.dart';
|
||||||
import '../../../sample/reward_sample_data.dart';
|
import '../../../sample/reward_sample_data.dart';
|
||||||
import '../../components/assets/assets.gen.dart';
|
import '../../components/assets/assets.gen.dart';
|
||||||
|
import '../../components/delegate/filter_bar_delegate.dart';
|
||||||
import '../../router/app_router.gr.dart';
|
import '../../router/app_router.gr.dart';
|
||||||
import 'reward_ui.dart';
|
import 'reward_ui.dart';
|
||||||
import 'widgets/reward_card.dart';
|
import 'widgets/reward_card.dart';
|
||||||
@@ -65,8 +66,10 @@ class _RewardPageState extends State<RewardPage> {
|
|||||||
SliverToBoxAdapter(child: _buildSummary()),
|
SliverToBoxAdapter(child: _buildSummary()),
|
||||||
SliverPersistentHeader(
|
SliverPersistentHeader(
|
||||||
pinned: true,
|
pinned: true,
|
||||||
delegate: _FilterHeaderDelegate(
|
delegate: FilterBarDelegate<_RewardFilter>(
|
||||||
|
values: _RewardFilter.values,
|
||||||
selected: _filter,
|
selected: _filter,
|
||||||
|
labelOf: (filter) => filter.label,
|
||||||
countOf: _countOf,
|
countOf: _countOf,
|
||||||
onSelected: (filter) => setState(() => _filter = filter),
|
onSelected: (filter) => setState(() => _filter = filter),
|
||||||
),
|
),
|
||||||
@@ -292,117 +295,3 @@ class _RewardPageState extends State<RewardPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FilterHeaderDelegate extends SliverPersistentHeaderDelegate {
|
|
||||||
_FilterHeaderDelegate({
|
|
||||||
required this.selected,
|
|
||||||
required this.countOf,
|
|
||||||
required this.onSelected,
|
|
||||||
});
|
|
||||||
|
|
||||||
final _RewardFilter selected;
|
|
||||||
final int Function(_RewardFilter) countOf;
|
|
||||||
final ValueChanged<_RewardFilter> onSelected;
|
|
||||||
|
|
||||||
static const double _height = 56;
|
|
||||||
|
|
||||||
@override
|
|
||||||
double get minExtent => _height;
|
|
||||||
|
|
||||||
@override
|
|
||||||
double get maxExtent => _height;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(
|
|
||||||
BuildContext context,
|
|
||||||
double shrinkOffset,
|
|
||||||
bool overlapsContent,
|
|
||||||
) {
|
|
||||||
return Container(
|
|
||||||
color: AppColor.background,
|
|
||||||
child: ListView.separated(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
||||||
itemCount: _RewardFilter.values.length,
|
|
||||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final filter = _RewardFilter.values[index];
|
|
||||||
return _FilterChip(
|
|
||||||
label: filter.label,
|
|
||||||
count: countOf(filter),
|
|
||||||
isSelected: filter == selected,
|
|
||||||
onTap: () => onSelected(filter),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRebuild(covariant _FilterHeaderDelegate oldDelegate) =>
|
|
||||||
oldDelegate.selected != selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FilterChip extends StatelessWidget {
|
|
||||||
const _FilterChip({
|
|
||||||
required this.label,
|
|
||||||
required this.count,
|
|
||||||
required this.isSelected,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String label;
|
|
||||||
final int count;
|
|
||||||
final bool isSelected;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final foreground = isSelected ? AppColor.textWhite : AppColor.textPrimary;
|
|
||||||
|
|
||||||
return Material(
|
|
||||||
color: isSelected ? AppColor.primary : AppColor.white,
|
|
||||||
shape: StadiumBorder(
|
|
||||||
side: BorderSide(
|
|
||||||
color: isSelected ? AppColor.primary : AppColor.border,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: InkWell(
|
|
||||||
customBorder: const StadiumBorder(),
|
|
||||||
onTap: onTap,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: AppStyle.sm.copyWith(
|
|
||||||
color: foreground,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected
|
|
||||||
? AppColor.white.withOpacity(0.2)
|
|
||||||
: AppColor.primary.withOpacity(0.08),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'$count',
|
|
||||||
style: AppStyle.xs.copyWith(
|
|
||||||
color: isSelected ? AppColor.textWhite : AppColor.primary,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ class AppRouter extends RootStackRouter {
|
|||||||
// Reward
|
// Reward
|
||||||
AutoRoute(page: RewardRoute.page),
|
AutoRoute(page: RewardRoute.page),
|
||||||
|
|
||||||
|
// Promo
|
||||||
|
AutoRoute(page: PromoRoute.page),
|
||||||
|
AutoRoute(page: PromoDetailRoute.page),
|
||||||
|
|
||||||
// Account
|
// Account
|
||||||
AutoRoute(page: AccountMyRoute.page),
|
AutoRoute(page: AccountMyRoute.page),
|
||||||
AutoRoute(page: AddressRoute.page),
|
AutoRoute(page: AddressRoute.page),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,209 @@
|
|||||||
|
// Models
|
||||||
|
//
|
||||||
|
// Mengikuti bentuk campaign di backend: tipe hadiah (POINTS / COINS / REWARD)
|
||||||
|
// dengan satu aturan (min. belanja, menu, hari, atau outlet) dan periode.
|
||||||
|
|
||||||
|
/// Hadiah yang didapat customer dari promo.
|
||||||
|
enum PromoType { point, coin, reward }
|
||||||
|
|
||||||
|
class Promo {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// Satu kalimat pendek untuk kartu.
|
||||||
|
final String subtitle;
|
||||||
|
final PromoType type;
|
||||||
|
|
||||||
|
/// Hadiah dalam bentuk ringkas, mis. "2x EnakPoint" atau "+3 EnakCoin".
|
||||||
|
final String rewardLabel;
|
||||||
|
final String description;
|
||||||
|
final List<String> steps;
|
||||||
|
final List<String> terms;
|
||||||
|
|
||||||
|
/// Kosong berarti berlaku di semua outlet.
|
||||||
|
final List<String> outlets;
|
||||||
|
final DateTime startDate;
|
||||||
|
final DateTime endDate;
|
||||||
|
final bool isFeatured;
|
||||||
|
|
||||||
|
const Promo({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.type,
|
||||||
|
required this.rewardLabel,
|
||||||
|
required this.description,
|
||||||
|
required this.steps,
|
||||||
|
required this.terms,
|
||||||
|
required this.startDate,
|
||||||
|
required this.endDate,
|
||||||
|
this.outlets = const [],
|
||||||
|
this.isFeatured = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample Data
|
||||||
|
//
|
||||||
|
// 1 EnakPoint = potongan Rp 1. Belanja biasa memberi sekitar 1% EnakPoint.
|
||||||
|
DateTime _daysAgo(int days) => DateTime.now().subtract(Duration(days: days));
|
||||||
|
DateTime _daysFromNow(int days) => DateTime.now().add(Duration(days: days));
|
||||||
|
|
||||||
|
const String _stepLinkPhone =
|
||||||
|
'Sebutkan nomor HP akunmu ke kasir sebelum membayar.';
|
||||||
|
|
||||||
|
/// Getter, bukan `final`, supaya perubahan data langsung terbaca saat hot
|
||||||
|
/// reload (variabel global yang sudah terisi tidak diisi ulang).
|
||||||
|
List<Promo> get promoSamples => [
|
||||||
|
Promo(
|
||||||
|
id: 'p1',
|
||||||
|
title: 'Double EnakPoint Akhir Pekan',
|
||||||
|
subtitle: 'Semua belanja Sabtu & Minggu dapat EnakPoint 2x lipat',
|
||||||
|
type: PromoType.point,
|
||||||
|
rewardLabel: '2x EnakPoint',
|
||||||
|
description:
|
||||||
|
'Belanja di akhir pekan jadi lebih untung. Setiap transaksi pada hari '
|
||||||
|
'Sabtu dan Minggu mendapat EnakPoint dua kali lipat dari biasanya.',
|
||||||
|
steps: [
|
||||||
|
'Belanja di outlet mana pun pada hari Sabtu atau Minggu.',
|
||||||
|
_stepLinkPhone,
|
||||||
|
'EnakPoint 2x otomatis masuk setelah pesanan lunas.',
|
||||||
|
],
|
||||||
|
terms: [
|
||||||
|
'Berlaku untuk semua menu, kecuali pembelian EnakCoin.',
|
||||||
|
'Bonus dihitung dari total belanja setelah diskon.',
|
||||||
|
'Maksimal bonus 5.000 EnakPoint per transaksi.',
|
||||||
|
],
|
||||||
|
startDate: _daysAgo(6),
|
||||||
|
endDate: _daysFromNow(24),
|
||||||
|
isFeatured: true,
|
||||||
|
),
|
||||||
|
Promo(
|
||||||
|
id: 'p2',
|
||||||
|
title: 'Ngopi Dapat EnakCoin',
|
||||||
|
subtitle: 'Setiap Kopi Susu di Tumulu Coffee dapat +3 EnakCoin',
|
||||||
|
type: PromoType.coin,
|
||||||
|
rewardLabel: '+3 EnakCoin',
|
||||||
|
description:
|
||||||
|
'Kopi pagi sekaligus modal main EnakGame. Setiap pembelian Kopi Susu '
|
||||||
|
'di Tumulu Coffee memberi 3 EnakCoin tambahan.',
|
||||||
|
steps: [
|
||||||
|
'Pesan Kopi Susu ukuran apa pun di Tumulu Coffee.',
|
||||||
|
_stepLinkPhone,
|
||||||
|
'EnakCoin masuk otomatis setelah pesanan lunas.',
|
||||||
|
],
|
||||||
|
terms: [
|
||||||
|
'Berlaku kelipatan: 2 Kopi Susu = +6 EnakCoin.',
|
||||||
|
'Maksimal 15 EnakCoin per hari.',
|
||||||
|
],
|
||||||
|
outlets: ['Tumulu Coffee'],
|
||||||
|
startDate: _daysAgo(4),
|
||||||
|
endDate: _daysFromNow(10),
|
||||||
|
isFeatured: true,
|
||||||
|
),
|
||||||
|
Promo(
|
||||||
|
id: 'p3',
|
||||||
|
title: 'Belanja Rp 100 Ribu, Bonus 2.000 EnakPoint',
|
||||||
|
subtitle: 'Bonus langsung untuk belanja minimal Rp 100.000',
|
||||||
|
type: PromoType.point,
|
||||||
|
rewardLabel: '+2.000 EnakPoint',
|
||||||
|
description:
|
||||||
|
'Dapatkan bonus 2.000 EnakPoint, setara potongan Rp 2.000 di belanja '
|
||||||
|
'berikutnya, untuk setiap transaksi minimal Rp 100.000.',
|
||||||
|
steps: [
|
||||||
|
'Belanja minimal Rp 100.000 dalam satu transaksi.',
|
||||||
|
_stepLinkPhone,
|
||||||
|
'Bonus masuk otomatis, di luar EnakPoint belanja biasa.',
|
||||||
|
],
|
||||||
|
terms: [
|
||||||
|
'Minimum belanja dihitung sebelum pajak.',
|
||||||
|
'Berlaku 1 kali per customer per hari.',
|
||||||
|
],
|
||||||
|
startDate: _daysAgo(12),
|
||||||
|
endDate: _daysFromNow(2),
|
||||||
|
),
|
||||||
|
Promo(
|
||||||
|
id: 'p4',
|
||||||
|
title: 'Gratis Es Teh Manis',
|
||||||
|
subtitle: 'Belanja Rp 50.000 di Bakso 343, dapat reward Es Teh Manis',
|
||||||
|
type: PromoType.reward,
|
||||||
|
rewardLabel: 'Gratis Es Teh',
|
||||||
|
description:
|
||||||
|
'Makan bakso makin segar. Belanja minimal Rp 50.000 di Bakso 343 dan '
|
||||||
|
'dapatkan reward 1 Es Teh Manis untuk kunjungan berikutnya.',
|
||||||
|
steps: [
|
||||||
|
'Belanja minimal Rp 50.000 di Bakso 343.',
|
||||||
|
_stepLinkPhone,
|
||||||
|
'Reward masuk ke Reward Saya dan berlaku 14 hari.',
|
||||||
|
],
|
||||||
|
terms: [
|
||||||
|
'Reward hanya bisa dipakai di Bakso 343.',
|
||||||
|
'Maksimal 1 reward per customer per hari.',
|
||||||
|
],
|
||||||
|
outlets: ['Bakso 343'],
|
||||||
|
startDate: _daysAgo(2),
|
||||||
|
endDate: _daysFromNow(14),
|
||||||
|
isFeatured: true,
|
||||||
|
),
|
||||||
|
Promo(
|
||||||
|
id: 'p5',
|
||||||
|
title: 'Rabu Ramen',
|
||||||
|
subtitle: 'Setiap Rabu, tiap porsi ramen dapat +1.500 EnakPoint',
|
||||||
|
type: PromoType.point,
|
||||||
|
rewardLabel: '+1.500 EnakPoint',
|
||||||
|
description:
|
||||||
|
'Hari Rabu jadi hari ramen. Setiap porsi ramen di Ramenmulu pada hari '
|
||||||
|
'Rabu memberi bonus 1.500 EnakPoint.',
|
||||||
|
steps: [
|
||||||
|
'Pesan ramen di Ramenmulu pada hari Rabu.',
|
||||||
|
_stepLinkPhone,
|
||||||
|
'Bonus masuk otomatis setelah pesanan lunas.',
|
||||||
|
],
|
||||||
|
terms: [
|
||||||
|
'Berlaku untuk semua varian ramen, tidak termasuk topping.',
|
||||||
|
'Maksimal 3 porsi per transaksi.',
|
||||||
|
],
|
||||||
|
outlets: ['Ramenmulu'],
|
||||||
|
startDate: _daysAgo(20),
|
||||||
|
endDate: _daysFromNow(21),
|
||||||
|
),
|
||||||
|
Promo(
|
||||||
|
id: 'p6',
|
||||||
|
title: 'Modal Main EnakGame',
|
||||||
|
subtitle: 'Belanja Rp 75.000 dapat +5 EnakCoin',
|
||||||
|
type: PromoType.coin,
|
||||||
|
rewardLabel: '+5 EnakCoin',
|
||||||
|
description:
|
||||||
|
'Tambah kesempatan menang hadiah di EnakGame. Setiap belanja minimal '
|
||||||
|
'Rp 75.000 memberi 5 EnakCoin tambahan.',
|
||||||
|
steps: [
|
||||||
|
'Belanja minimal Rp 75.000 di outlet mana pun.',
|
||||||
|
_stepLinkPhone,
|
||||||
|
'Pakai EnakCoin untuk main EnakGame.',
|
||||||
|
],
|
||||||
|
terms: [
|
||||||
|
'Berlaku 1 kali per customer per hari.',
|
||||||
|
'EnakCoin mengikuti masa berlaku yang diatur outlet.',
|
||||||
|
],
|
||||||
|
startDate: _daysAgo(1),
|
||||||
|
endDate: _daysFromNow(7),
|
||||||
|
),
|
||||||
|
Promo(
|
||||||
|
id: 'p7',
|
||||||
|
title: 'Pekan Woku Pedas',
|
||||||
|
subtitle: 'Menu apa pun di Woku Pedas dapat EnakPoint 3x lipat',
|
||||||
|
type: PromoType.point,
|
||||||
|
rewardLabel: '3x EnakPoint',
|
||||||
|
description:
|
||||||
|
'Rayakan pembukaan menu baru Woku Pedas. Selama sepekan, semua '
|
||||||
|
'belanja di Woku Pedas mendapat EnakPoint tiga kali lipat.',
|
||||||
|
steps: [
|
||||||
|
'Belanja di Woku Pedas selama periode promo.',
|
||||||
|
_stepLinkPhone,
|
||||||
|
'EnakPoint 3x otomatis masuk setelah pesanan lunas.',
|
||||||
|
],
|
||||||
|
terms: ['Maksimal bonus 10.000 EnakPoint per transaksi.'],
|
||||||
|
outlets: ['Woku Pedas'],
|
||||||
|
startDate: _daysFromNow(3),
|
||||||
|
endDate: _daysFromNow(10),
|
||||||
|
),
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user