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(
|
||||
label: 'EnakPromo',
|
||||
icon: Assets.icons.enakpromoIcon,
|
||||
route: ComingSoonRoute(
|
||||
title: 'EnakPromo',
|
||||
iconPath: Assets.icons.enakpromoIcon.path,
|
||||
),
|
||||
route: const PromoRoute(),
|
||||
),
|
||||
_HomeMenu(
|
||||
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 '../../../sample/reward_sample_data.dart';
|
||||
import '../../components/assets/assets.gen.dart';
|
||||
import '../../components/delegate/filter_bar_delegate.dart';
|
||||
import '../../router/app_router.gr.dart';
|
||||
import 'reward_ui.dart';
|
||||
import 'widgets/reward_card.dart';
|
||||
@@ -65,8 +66,10 @@ class _RewardPageState extends State<RewardPage> {
|
||||
SliverToBoxAdapter(child: _buildSummary()),
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: _FilterHeaderDelegate(
|
||||
delegate: FilterBarDelegate<_RewardFilter>(
|
||||
values: _RewardFilter.values,
|
||||
selected: _filter,
|
||||
labelOf: (filter) => filter.label,
|
||||
countOf: _countOf,
|
||||
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
|
||||
AutoRoute(page: RewardRoute.page),
|
||||
|
||||
// Promo
|
||||
AutoRoute(page: PromoRoute.page),
|
||||
AutoRoute(page: PromoDetailRoute.page),
|
||||
|
||||
// Account
|
||||
AutoRoute(page: AccountMyRoute.page),
|
||||
AutoRoute(page: AddressRoute.page),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user