Compare commits

...

7 Commits

Author SHA1 Message Date
efrilm
f70b43c6ab feat: merchat detail page 2025-08-29 16:07:01 +07:00
efrilm
c36a4059e1 feat: draw detail page 2025-08-29 15:40:45 +07:00
efrilm
8951ca44eb feat: update 2025-08-29 15:32:26 +07:00
efrilm
ec1beeeb1b feat: draw page 2025-08-29 15:30:15 +07:00
efrilm
2870f39bcb feat: product redeem page 2025-08-29 14:39:05 +07:00
efrilm
fb7c9a19a9 feat: reward page 2025-08-29 14:25:21 +07:00
efrilm
d86c38e77c feat: merchant page 2025-08-28 01:11:19 +07:00
18 changed files with 4785 additions and 136 deletions

View File

@ -3,3 +3,4 @@ import 'package:flutter/material.dart';
import '../../../common/theme/theme.dart';
part 'text_form_field.dart';
part 'search_text_form_field.dart';

View File

@ -0,0 +1,58 @@
part of 'field.dart';
class SearchTextField extends StatelessWidget {
final String hintText;
final IconData prefixIcon;
final TextEditingController? controller;
final Function()? onClear;
const SearchTextField({
super.key,
required this.hintText,
required this.prefixIcon,
this.controller,
this.onClear,
});
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.fromLTRB(16, 0, 16, 16),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: Offset(0, 2),
),
],
),
child: TextField(
cursorColor: AppColor.primary,
controller: controller,
decoration: InputDecoration(
hintText: hintText,
hintStyle: TextStyle(color: AppColor.textLight, fontSize: 14),
disabledBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedBorder: InputBorder.none,
prefixIcon: Container(
margin: EdgeInsets.all(12),
width: 24,
height: 24,
decoration: BoxDecoration(
color: AppColor.primary,
borderRadius: BorderRadius.circular(6),
),
child: Icon(prefixIcon, color: AppColor.white, size: 14),
),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
),
);
}
}

View File

@ -43,7 +43,7 @@ class ImagePlaceholder extends StatelessWidget {
: null,
),
child: Center(
child: minDimension < 100
child: minDimension < 150
? _buildSimpleVersion(minDimension)
: _buildDetailedVersion(minDimension),
),

View File

@ -0,0 +1,606 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../common/theme/theme.dart';
import '../../router/app_router.gr.dart';
// Models (simplified)
class DrawEvent {
final String id;
final String name;
final String description;
final int entryPoints;
final String icon;
final Color primaryColor;
final String prize;
final String prizeValue;
final DateTime drawDate;
final int totalParticipants;
final int hadiah;
final String status; // 'active', 'ended'
final int minSpending;
DrawEvent({
required this.id,
required this.name,
required this.description,
required this.entryPoints,
required this.icon,
required this.primaryColor,
required this.prize,
required this.prizeValue,
required this.drawDate,
required this.totalParticipants,
required this.hadiah,
required this.status,
required this.minSpending,
});
bool get isActive => status == 'active';
}
class UserEntry {
final String drawId;
final DateTime entryDate;
UserEntry({required this.drawId, required this.entryDate});
}
@RoutePage()
class DrawPage extends StatefulWidget {
const DrawPage({super.key});
@override
State<DrawPage> createState() => _DrawPageState();
}
class _DrawPageState extends State<DrawPage> {
String selectedTab = 'active'; // 'active' or 'finished'
final List<UserEntry> userEntries = [
UserEntry(
drawId: "1",
entryDate: DateTime.now().subtract(Duration(hours: 3)),
),
];
final List<DrawEvent> drawEvents = [
DrawEvent(
id: "1",
name: "Emas 3 Gram",
description: "Gebyar Undian Enaklo\nMenangkan hadiah menarik",
entryPoints: 0,
icon: "👑",
primaryColor: AppColor.primary,
prize: "Emas 3 Gram",
prizeValue: "Rp 2.500.000",
drawDate: DateTime.now().add(Duration(hours: 1, minutes: 20)),
totalParticipants: 0,
hadiah: 2,
status: 'active',
minSpending: 50000,
),
DrawEvent(
id: "2",
name: "iPhone 15 Pro",
description: "Undian Smartphone Premium\nDapatkan iPhone terbaru",
entryPoints: 0,
icon: "📱",
primaryColor: AppColor.info,
prize: "iPhone 15 Pro",
prizeValue: "Rp 18.000.000",
drawDate: DateTime.now().subtract(Duration(days: 1)),
totalParticipants: 156,
hadiah: 1,
status: 'ended',
minSpending: 100000,
),
];
List<DrawEvent> get filteredDraws {
return drawEvents.where((draw) {
if (selectedTab == 'active') {
return draw.isActive;
} else {
return !draw.isActive;
}
}).toList();
}
bool _isUserEntered(String drawId) {
return userEntries.any((entry) => entry.drawId == drawId);
}
String _getTimeRemaining(DateTime targetDate) {
final now = DateTime.now();
final difference = targetDate.difference(now);
if (difference.isNegative) return "Berakhir";
if (difference.inHours > 0) {
return "${difference.inHours}h ${difference.inMinutes % 60}m";
} else if (difference.inMinutes > 0) {
return "${difference.inMinutes}m";
} else {
return "Sekarang!";
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColor.background,
appBar: AppBar(title: Text("Undian")),
body: Column(
children: [
// Tab selector
Container(
margin: EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: AppColor.black.withOpacity(0.05),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => setState(() => selectedTab = 'active'),
child: Container(
padding: EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: selectedTab == 'active'
? AppColor.primary
: Colors.transparent,
borderRadius: BorderRadius.circular(25),
),
child: Text(
"Aktif (${drawEvents.where((d) => d.isActive).length})",
textAlign: TextAlign.center,
style: AppStyle.md.copyWith(
color: selectedTab == 'active'
? AppColor.textWhite
: AppColor.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
),
),
Expanded(
child: GestureDetector(
onTap: () => setState(() => selectedTab = 'finished'),
child: Container(
padding: EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: selectedTab == 'finished'
? AppColor.primary
: Colors.transparent,
borderRadius: BorderRadius.circular(25),
),
child: Text(
"Selesai (${drawEvents.where((d) => !d.isActive).length})",
textAlign: TextAlign.center,
style: AppStyle.md.copyWith(
color: selectedTab == 'finished'
? AppColor.textWhite
: AppColor.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
),
// Draw list
Expanded(
child: ListView.builder(
padding: EdgeInsets.symmetric(horizontal: 16),
itemCount: filteredDraws.length,
itemBuilder: (context, index) {
final draw = filteredDraws[index];
return _buildSimpleDrawCard(draw);
},
),
),
],
),
);
}
Widget _buildSimpleDrawCard(DrawEvent draw) {
_isUserEntered(draw.id);
final timeRemaining = _getTimeRemaining(draw.drawDate);
return GestureDetector(
onTap: () => context.router.push(DrawDetailRoute(drawEvent: draw)),
child: Container(
margin: EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppColor.black.withOpacity(0.08),
blurRadius: 12,
offset: Offset(0, 4),
),
],
),
child: Column(
children: [
// Header with gradient background
Container(
height: 160,
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
draw.primaryColor,
draw.primaryColor.withOpacity(0.8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
child: Stack(
children: [
// Background pattern (coins and gold bar)
Positioned(
right: 20,
top: 20,
child: Opacity(
opacity: 0.3,
child: Column(
children: [
Row(
children: [
_buildCoin(),
SizedBox(width: 8),
_buildCoin(),
],
),
SizedBox(height: 8),
_buildGoldBar(),
],
),
),
),
// Content
Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (draw.isActive)
Container(
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColor.success,
borderRadius: BorderRadius.circular(4),
),
child: Text(
"AKTIF",
style: AppStyle.xs.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.bold,
),
),
),
SizedBox(height: 8),
Text(
"MAKAN\nDAPAT",
style: AppStyle.h5.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.bold,
height: 0.9,
),
),
SizedBox(height: 4),
Text(
draw.description,
style: AppStyle.sm.copyWith(
color: AppColor.textWhite,
),
),
],
),
),
],
),
),
// Card content
Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
// Prize info
Row(
children: [
Text(draw.icon, style: AppStyle.h5),
SizedBox(width: 8),
Text(
draw.prize,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
],
),
SizedBox(height: 16),
// Stats row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
children: [
Text(
"-",
style: AppStyle.h5.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
Text(
"Peserta",
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
),
],
),
Column(
children: [
Text(
"${draw.hadiah}",
style: AppStyle.h5.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
Text(
"Hadiah",
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
),
],
),
Column(
children: [
Text(
"0",
style: AppStyle.h5.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.error,
),
),
Text(
"Voucher Anda",
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
),
],
),
],
),
SizedBox(height: 16),
// Timer
if (draw.isActive)
Row(
children: [
Icon(
Icons.access_time,
color: AppColor.error,
size: 16,
),
SizedBox(width: 4),
Text(
"Berakhir dalam:",
style: AppStyle.md.copyWith(
color: AppColor.textPrimary,
),
),
Spacer(),
Text(
timeRemaining,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.error,
),
),
],
),
SizedBox(height: 12),
// Spending requirement
GestureDetector(
onTap: () => _showSpendingInfo(draw),
child: Container(
padding: EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
decoration: BoxDecoration(
color: AppColor.backgroundLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColor.borderLight,
width: 1,
),
),
child: Row(
children: [
Expanded(
child: Text(
"Belanja min. Rp ${_formatCurrency(draw.minSpending)} untuk berpartisipasi",
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
),
),
Icon(
Icons.chevron_right,
color: AppColor.textSecondary,
size: 16,
),
],
),
),
),
],
),
),
],
),
),
);
}
Widget _buildCoin() {
return Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: AppColor.warning,
shape: BoxShape.circle,
border: Border.all(color: Colors.yellow, width: 1),
),
child: Center(
child: Text(
"",
style: AppStyle.xs.copyWith(
color: Colors.orange[800],
fontWeight: FontWeight.bold,
),
),
),
);
}
Widget _buildGoldBar() {
return Container(
width: 30,
height: 15,
decoration: BoxDecoration(
color: AppColor.warning,
borderRadius: BorderRadius.circular(2),
border: Border.all(color: Colors.yellow, width: 1),
),
child: Center(
child: Text(
"GOLD",
style: TextStyle(
color: Colors.orange[800],
fontSize: 6,
fontWeight: FontWeight.bold,
),
),
),
);
}
String _formatCurrency(int amount) {
return amount.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]}.',
);
}
void _showSpendingInfo(DrawEvent draw) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColor.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Text(
"Syarat Partisipasi",
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Untuk mengikuti undian ${draw.name}, Anda perlu:",
style: AppStyle.md.copyWith(color: AppColor.textPrimary),
),
SizedBox(height: 12),
Row(
children: [
Icon(Icons.shopping_cart, color: draw.primaryColor, size: 20),
SizedBox(width: 8),
Expanded(
child: Text(
"Belanja minimum Rp ${_formatCurrency(draw.minSpending)}",
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
),
],
),
SizedBox(height: 8),
Row(
children: [
Icon(Icons.schedule, color: draw.primaryColor, size: 20),
SizedBox(width: 8),
Expanded(
child: Text(
"Sebelum ${_formatDateTime(draw.drawDate)}",
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
"Mengerti",
style: AppStyle.md.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
String _formatDateTime(DateTime date) {
return "${date.day}/${date.month}/${date.year} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}";
}
}

View File

@ -0,0 +1,905 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../../../common/theme/theme.dart';
import '../../draw_page.dart';
// Prize model
class Prize {
final String id;
final String name;
final String value;
final String icon;
final int quantity;
final String description;
Prize({
required this.id,
required this.name,
required this.value,
required this.icon,
required this.quantity,
required this.description,
});
}
// Voucher model
class UserVoucher {
final String id;
final String drawId;
final String voucherNumber;
final DateTime createdDate;
final String status; // 'active', 'used', 'expired'
UserVoucher({
required this.id,
required this.drawId,
required this.voucherNumber,
required this.createdDate,
required this.status,
});
}
@RoutePage()
class DrawDetailPage extends StatefulWidget {
final DrawEvent drawEvent;
const DrawDetailPage({super.key, required this.drawEvent});
@override
State<DrawDetailPage> createState() => _DrawDetailPageState();
}
class _DrawDetailPageState extends State<DrawDetailPage>
with TickerProviderStateMixin {
late TabController _tabController;
// Sample data
final List<Prize> prizes = [
Prize(
id: "1",
name: "Emas 3 Gram",
value: "Rp 2.500.000",
icon: "👑",
quantity: 1,
description:
"Emas murni 24 karat seberat 3 gram dari toko emas terpercaya",
),
Prize(
id: "2",
name: "Emas 1 Gram",
value: "Rp 850.000",
icon: "🥇",
quantity: 1,
description:
"Emas murni 24 karat seberat 1 gram dari toko emas terpercaya",
),
];
final List<UserVoucher> userVouchers = [
UserVoucher(
id: "1",
drawId: "1",
voucherNumber: "ENK001234567",
createdDate: DateTime.now().subtract(Duration(hours: 2)),
status: 'active',
),
];
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
String _getTimeRemaining(DateTime targetDate) {
final now = DateTime.now();
final difference = targetDate.difference(now);
if (difference.isNegative) return "Sudah berakhir";
if (difference.inDays > 0) {
return "${difference.inDays} hari ${difference.inHours % 24} jam";
} else if (difference.inHours > 0) {
return "${difference.inHours} jam ${difference.inMinutes % 60} menit";
} else if (difference.inMinutes > 0) {
return "${difference.inMinutes} menit";
} else {
return "Berakhir sekarang!";
}
}
String _formatCurrency(int amount) {
return amount.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]}.',
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColor.background,
body: CustomScrollView(
slivers: [
// App Bar with gradient
SliverAppBar(
expandedHeight: 200,
pinned: true,
backgroundColor: widget.drawEvent.primaryColor,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: AppColor.textWhite),
onPressed: () => Navigator.of(context).pop(),
),
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
widget.drawEvent.primaryColor,
widget.drawEvent.primaryColor.withOpacity(0.8),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Stack(
children: [
// Background decoration
Positioned(
right: 20,
top: 60,
child: Opacity(
opacity: 0.2,
child: Column(
children: [
Row(
children: [
_buildCoin(),
SizedBox(width: 8),
_buildCoin(),
SizedBox(width: 8),
_buildCoin(),
],
),
SizedBox(height: 12),
_buildGoldBar(),
],
),
),
),
// Content
Padding(
padding: EdgeInsets.only(left: 20, right: 20, top: 100),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.drawEvent.isActive)
Container(
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColor.success,
borderRadius: BorderRadius.circular(20),
),
child: Text(
"UNDIAN AKTIF",
style: AppStyle.xs.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.bold,
),
),
),
SizedBox(height: 8),
Text(
widget.drawEvent.name,
style: AppStyle.h3.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.bold,
),
),
Text(
widget.drawEvent.description.split('\n').first,
style: AppStyle.md.copyWith(
color: AppColor.textWhite.withOpacity(0.9),
),
),
],
),
),
],
),
),
),
),
// Tab Bar
SliverPersistentHeader(
pinned: true,
delegate: _SliverTabBarDelegate(
TabBar(
controller: _tabController,
labelColor: AppColor.primary,
unselectedLabelColor: AppColor.textSecondary,
indicatorColor: AppColor.primary,
indicatorWeight: 3,
labelStyle: AppStyle.md.copyWith(fontWeight: FontWeight.w600),
unselectedLabelStyle: AppStyle.md,
tabs: [
Tab(text: "Info"),
Tab(text: "Hadiah"),
Tab(text: "Voucher"),
Tab(text: "S&K"),
],
),
),
),
// Tab Content
SliverFillRemaining(
child: TabBarView(
controller: _tabController,
children: [
_buildInfoTab(),
_buildPrizesTab(),
_buildVouchersTab(),
_buildTermsTab(),
],
),
),
],
),
);
}
Widget _buildInfoTab() {
final timeRemaining = _getTimeRemaining(widget.drawEvent.drawDate);
return SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoCard(
title: "Informasi Undian",
children: [
_buildInfoRow("Nama Undian", widget.drawEvent.name),
_buildInfoRow("Deskripsi", widget.drawEvent.description),
_buildInfoRow(
"Total Hadiah",
"${widget.drawEvent.hadiah} hadiah",
),
_buildInfoRow("Nilai Hadiah", widget.drawEvent.prizeValue),
_buildInfoRow(
"Status",
widget.drawEvent.isActive ? "Aktif" : "Selesai",
),
],
),
SizedBox(height: 16),
_buildInfoCard(
title: "Waktu Undian",
children: [
_buildInfoRow(
"Tanggal Pengundian",
_formatDateTime(widget.drawEvent.drawDate),
),
_buildInfoRow("Waktu Tersisa", timeRemaining),
],
),
SizedBox(height: 16),
_buildInfoCard(
title: "Statistik",
children: [
_buildInfoRow(
"Total Peserta",
"${widget.drawEvent.totalParticipants} orang",
),
_buildInfoRow(
"Voucher Anda",
"${userVouchers.where((v) => v.drawId == widget.drawEvent.id).length}",
),
_buildInfoRow(
"Minimum Belanja",
"Rp ${_formatCurrency(widget.drawEvent.minSpending)}",
),
],
),
],
),
);
}
Widget _buildPrizesTab() {
return SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Daftar Hadiah",
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SizedBox(height: 16),
...prizes.map((prize) => _buildPrizeCard(prize)).toList(),
SizedBox(height: 16),
_buildInfoCard(
title: "Ketentuan Hadiah",
children: [
_buildBulletPoint(
"Hadiah akan diumumkan setelah pengundian selesai",
),
_buildBulletPoint(
"Pemenang akan dihubungi melalui nomor telepon terdaftar",
),
_buildBulletPoint("Hadiah harus diambil dalam waktu 30 hari"),
_buildBulletPoint(
"Hadiah tidak dapat dipindahtangankan atau ditukar uang",
),
],
),
],
),
);
}
Widget _buildVouchersTab() {
final drawVouchers = userVouchers
.where((v) => v.drawId == widget.drawEvent.id)
.toList();
return SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Voucher Anda",
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SizedBox(height: 8),
Text(
"Total: ${drawVouchers.length} voucher",
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
),
SizedBox(height: 16),
if (drawVouchers.isEmpty)
_buildEmptyVoucherState()
else
...drawVouchers
.map((voucher) => _buildVoucherCard(voucher))
.toList(),
SizedBox(height: 16),
_buildInfoCard(
title: "Cara Mendapat Voucher",
children: [
_buildBulletPoint(
"Lakukan pembelian minimum Rp ${_formatCurrency(widget.drawEvent.minSpending)}",
),
_buildBulletPoint("Voucher otomatis akan masuk ke akun Anda"),
_buildBulletPoint(
"Semakin banyak voucher, semakin besar peluang menang",
),
_buildBulletPoint("Voucher berlaku hingga pengundian selesai"),
],
),
],
),
);
}
Widget _buildTermsTab() {
return SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Syarat dan Ketentuan",
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SizedBox(height: 16),
_buildTermsSection(
title: "Syarat Partisipasi",
terms: [
"Pengguna harus terdaftar sebagai member Enaklo",
"Melakukan pembelian minimum Rp ${_formatCurrency(widget.drawEvent.minSpending)}",
"Pembelian harus dilakukan sebelum waktu pengundian",
"Satu transaksi pembelian = satu voucher undian",
],
),
_buildTermsSection(
title: "Ketentuan Pengundian",
terms: [
"Pengundian dilakukan secara transparan dan fair",
"Pemenang ditentukan secara acak oleh sistem",
"Keputusan pengundian bersifat final dan tidak dapat diganggu gugat",
"Pengumuman pemenang akan dilakukan maksimal 3 hari setelah pengundian",
],
),
_buildTermsSection(
title: "Ketentuan Hadiah",
terms: [
"Hadiah harus diambil dalam waktu 30 hari setelah pengumuman",
"Hadiah yang tidak diambil dalam batas waktu dianggap hangus",
"Hadiah tidak dapat ditukar dengan uang tunai",
"Pajak hadiah (jika ada) menjadi tanggung jawab pemenang",
],
),
_buildTermsSection(
title: "Ketentuan Lainnya",
terms: [
"Enaklo berhak membatalkan undian jika terjadi kecurangan",
"Peserta bertanggung jawab atas kebenaran data yang diberikan",
"Enaklo tidak bertanggung jawab atas kerugian akibat kesalahan peserta",
"Syarat dan ketentuan dapat berubah sewaktu-waktu tanpa pemberitahuan",
],
),
],
),
);
}
Widget _buildInfoCard({
required String title,
required List<Widget> children,
}) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppColor.black.withOpacity(0.05),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SizedBox(height: 12),
...children,
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
),
Text(
": ",
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
Expanded(
child: Text(
value,
style: AppStyle.sm.copyWith(
color: AppColor.textPrimary,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
Widget _buildPrizeCard(Prize prize) {
return Container(
margin: EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.borderLight),
boxShadow: [
BoxShadow(
color: AppColor.black.withOpacity(0.05),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: widget.drawEvent.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(30),
),
child: Center(child: Text(prize.icon, style: AppStyle.h4)),
),
SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
prize.name,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SizedBox(height: 4),
Text(
prize.value,
style: AppStyle.md.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 4),
Text(
prize.description,
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
"${prize.quantity}x",
style: AppStyle.sm.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
}
Widget _buildVoucherCard(UserVoucher voucher) {
Color statusColor;
String statusText;
switch (voucher.status) {
case 'active':
statusColor = AppColor.success;
statusText = "Aktif";
break;
case 'used':
statusColor = AppColor.textSecondary;
statusText = "Terpakai";
break;
case 'expired':
statusColor = AppColor.error;
statusText = "Kadaluarsa";
break;
default:
statusColor = AppColor.textSecondary;
statusText = "Tidak Diketahui";
}
return Container(
margin: EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: voucher.status == 'active'
? AppColor.primary.withOpacity(0.3)
: AppColor.borderLight,
),
boxShadow: [
BoxShadow(
color: AppColor.black.withOpacity(0.05),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Voucher Undian",
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
statusText,
style: AppStyle.xs.copyWith(
color: statusColor,
fontWeight: FontWeight.bold,
),
),
),
],
),
SizedBox(height: 8),
Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColor.backgroundLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColor.borderLight),
),
child: Center(
child: Text(
voucher.voucherNumber,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
letterSpacing: 1.2,
),
),
),
),
SizedBox(height: 8),
Text(
"Diperoleh: ${_formatDateTime(voucher.createdDate)}",
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
],
),
),
);
}
Widget _buildEmptyVoucherState() {
return Container(
width: double.infinity,
padding: EdgeInsets.all(32),
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.borderLight),
),
child: Column(
children: [
Icon(Icons.receipt_outlined, size: 64, color: AppColor.textLight),
SizedBox(height: 16),
Text(
"Belum Ada Voucher",
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
SizedBox(height: 8),
Text(
"Lakukan pembelian untuk mendapatkan voucher undian",
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
textAlign: TextAlign.center,
),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// Navigate to shop or show purchase dialog
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColor.primary,
foregroundColor: AppColor.textWhite,
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
"Belanja Sekarang",
style: AppStyle.md.copyWith(fontWeight: FontWeight.w600),
),
),
],
),
);
}
Widget _buildTermsSection({
required String title,
required List<String> terms,
}) {
return Container(
margin: EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppColor.black.withOpacity(0.05),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SizedBox(height: 12),
...terms.map((term) => _buildBulletPoint(term)).toList(),
],
),
),
);
}
Widget _buildBulletPoint(String text) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin: EdgeInsets.only(top: 6),
width: 4,
height: 4,
decoration: BoxDecoration(
color: AppColor.primary,
shape: BoxShape.circle,
),
),
SizedBox(width: 12),
Expanded(
child: Text(
text,
style: AppStyle.md.copyWith(color: AppColor.textPrimary),
),
),
],
),
);
}
Widget _buildCoin() {
return Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: AppColor.warning,
shape: BoxShape.circle,
border: Border.all(color: Colors.yellow, width: 1),
),
child: Center(
child: Text(
"",
style: AppStyle.sm.copyWith(
color: Colors.orange[800],
fontWeight: FontWeight.bold,
),
),
),
);
}
Widget _buildGoldBar() {
return Container(
width: 40,
height: 20,
decoration: BoxDecoration(
color: AppColor.warning,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.yellow, width: 1),
),
child: Center(
child: Text(
"GOLD",
style: AppStyle.xs.copyWith(
color: Colors.orange[800],
fontWeight: FontWeight.bold,
),
),
),
);
}
String _formatDateTime(DateTime date) {
return "${date.day}/${date.month}/${date.year} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}";
}
}
// Custom SliverTabBarDelegate
class _SliverTabBarDelegate extends SliverPersistentHeaderDelegate {
final TabBar _tabBar;
_SliverTabBarDelegate(this._tabBar);
@override
double get minExtent => _tabBar.preferredSize.height;
@override
double get maxExtent => _tabBar.preferredSize.height;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return Container(color: AppColor.surface, child: _tabBar);
}
@override
bool shouldRebuild(_SliverTabBarDelegate oldDelegate) {
return false;
}
}

View File

@ -1,4 +1,6 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../../../router/app_router.gr.dart';
import 'feature_card.dart';
class HomeFeatureSection extends StatelessWidget {
@ -15,19 +17,19 @@ class HomeFeatureSection extends StatelessWidget {
icon: Icons.card_giftcard,
title: 'Reward',
iconColor: const Color(0xFF1976D2),
onTap: () => print('Navigate to Reward'),
onTap: () => context.router.push(RewardRoute()),
),
HomeFeatureCard(
icon: Icons.casino,
title: 'Undian',
iconColor: const Color(0xFF7B1FA2),
onTap: () => print('Navigate to Undian'),
onTap: () => context.router.push(DrawRoute()),
),
HomeFeatureCard(
icon: Icons.store,
title: 'Merchant',
iconColor: const Color(0xFF388E3C),
onTap: () => print('Navigate to Merchant'),
onTap: () => context.router.push(MerchantRoute()),
),
],
),

View File

@ -1,6 +1,8 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../../../../common/theme/theme.dart';
import '../../../../../router/app_router.gr.dart';
import 'popular_merchant_card.dart';
class HomePopularMerchantSection extends StatelessWidget {
@ -19,7 +21,9 @@ class HomePopularMerchantSection extends StatelessWidget {
style: AppStyle.xl.copyWith(fontWeight: FontWeight.bold),
),
Spacer(),
Row(
InkWell(
onTap: () => context.router.push(MerchantRoute()),
child: Row(
children: [
Text(
'Lihat Semua',
@ -36,6 +40,7 @@ class HomePopularMerchantSection extends StatelessWidget {
),
],
),
),
],
),
SizedBox(height: 16),
@ -46,9 +51,7 @@ class HomePopularMerchantSection extends StatelessWidget {
rating: 4.8,
distance: '0.5 km',
isOpen: true,
onTap: () {
print('Warung Bu Sari tapped');
},
onTap: () {},
),
HomePopularMerchantCard(

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../../../common/theme/theme.dart';
@ -46,8 +47,9 @@ class OrderItem {
enum OrderStatus { pending, processing, completed, cancelled }
@RoutePage()
class OrderPage extends StatefulWidget {
const OrderPage({Key? key}) : super(key: key);
const OrderPage({super.key});
@override
State<OrderPage> createState() => _OrderPageState();

View File

@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../../../common/theme/theme.dart';
import '../../../../components/field/field.dart';
import 'widgets/voucher_card.dart';
@RoutePage()
@ -16,49 +17,9 @@ class VoucherPage extends StatelessWidget {
automaticallyImplyLeading: false,
bottom: PreferredSize(
preferredSize: Size.fromHeight(70),
child: Container(
margin: EdgeInsets.fromLTRB(16, 0, 16, 16),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: Offset(0, 2),
),
],
),
child: TextField(
cursorColor: AppColor.primary,
decoration: InputDecoration(
child: SearchTextField(
hintText: 'Punya kode promo? Masukkan disini',
hintStyle: TextStyle(color: AppColor.textLight, fontSize: 14),
disabledBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedBorder: InputBorder.none,
prefixIcon: Container(
margin: EdgeInsets.all(12),
width: 24,
height: 24,
decoration: BoxDecoration(
color: AppColor.primary,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.local_offer,
color: AppColor.white,
size: 14,
),
),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
),
prefixIcon: Icons.local_offer,
),
),
),

View File

@ -0,0 +1,189 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../common/theme/theme.dart';
import '../../components/field/field.dart';
import '../../router/app_router.gr.dart';
import 'widgets/empty_merchant_card.dart';
import 'widgets/merchant_card.dart';
@RoutePage()
class MerchantPage extends StatefulWidget {
const MerchantPage({super.key});
@override
State<MerchantPage> createState() => _MerchantPageState();
}
class _MerchantPageState extends State<MerchantPage> {
final TextEditingController _searchController = TextEditingController();
final List<MerchantModel> _allMerchants = _generateMockMerchants();
List<MerchantModel> _filteredMerchants = [];
@override
void initState() {
super.initState();
_filteredMerchants = _allMerchants;
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _filterMerchants(String query) {
setState(() {
if (query.isEmpty) {
_filteredMerchants = _allMerchants;
} else {
_filteredMerchants = _allMerchants
.where(
(merchant) =>
merchant.name.toLowerCase().contains(query.toLowerCase()) ||
merchant.category.toLowerCase().contains(query.toLowerCase()),
)
.toList();
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColor.background,
appBar: AppBar(
title: Text('Merchants'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(70),
child: SearchTextField(
hintText: 'Search merchants...',
prefixIcon: Icons.search,
controller: _searchController,
onClear: () {
_searchController.clear();
_filterMerchants('');
},
),
),
),
body: _filteredMerchants.isEmpty
? _buildEmptyState()
: Padding(
padding: const EdgeInsets.all(16),
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 0.85,
),
itemCount: _filteredMerchants.length,
itemBuilder: (context, index) {
return _buildMerchantCard(_filteredMerchants[index]);
},
),
),
);
}
Widget _buildMerchantCard(MerchantModel merchant) {
return MerchantCard(
merchant: merchant,
onTap: () {
context.router.push(MerchantDetailRoute(merchant: merchant));
},
);
}
Widget _buildEmptyState() {
return const EmptyMerchantCard();
}
static List<MerchantModel> _generateMockMerchants() {
return [
MerchantModel(
id: '1',
name: 'Warung Makan Sederhana',
category: 'Food & Beverage',
rating: 4.5,
isOpen: true,
imageUrl: 'https://via.placeholder.com/150',
),
MerchantModel(
id: '2',
name: 'Toko Elektronik',
category: 'Electronics',
rating: 4.2,
isOpen: true,
imageUrl: 'https://via.placeholder.com/150',
),
MerchantModel(
id: '3',
name: 'Apotek Sehat',
category: 'Healthcare',
rating: 4.8,
isOpen: false,
imageUrl: 'https://via.placeholder.com/150',
),
MerchantModel(
id: '4',
name: 'Fashion Store',
category: 'Clothing',
rating: 4.1,
isOpen: true,
imageUrl: 'https://via.placeholder.com/150',
),
MerchantModel(
id: '5',
name: 'Bengkel Motor',
category: 'Automotive',
rating: 4.3,
isOpen: true,
imageUrl: 'https://via.placeholder.com/150',
),
MerchantModel(
id: '6',
name: 'Minimarket 24',
category: 'Retail',
rating: 4.0,
isOpen: true,
imageUrl: 'https://via.placeholder.com/150',
),
MerchantModel(
id: '7',
name: 'Salon Kecantikan',
category: 'Beauty',
rating: 4.6,
isOpen: false,
imageUrl: 'https://via.placeholder.com/150',
),
MerchantModel(
id: '8',
name: 'Laundry Express',
category: 'Service',
rating: 4.4,
isOpen: true,
imageUrl: 'https://via.placeholder.com/150',
),
];
}
}
class MerchantModel {
final String id;
final String name;
final String category;
final double rating;
final bool isOpen;
final String imageUrl;
MerchantModel({
required this.id,
required this.name,
required this.category,
required this.rating,
required this.isOpen,
required this.imageUrl,
});
}

View File

@ -0,0 +1,725 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../../../common/theme/theme.dart';
import '../../merchant_page.dart';
// Models
class ProductCategory {
final String id;
final String name;
final String icon;
final int productCount;
ProductCategory({
required this.id,
required this.name,
required this.icon,
required this.productCount,
});
}
class Product {
final String id;
final String name;
final String description;
final int price;
final String categoryId;
final String imageUrl;
final bool isAvailable;
final double rating;
final int soldCount;
Product({
required this.id,
required this.name,
required this.description,
required this.price,
required this.categoryId,
required this.imageUrl,
required this.isAvailable,
required this.rating,
required this.soldCount,
});
}
@RoutePage()
class MerchantDetailPage extends StatefulWidget {
final MerchantModel merchant;
const MerchantDetailPage({super.key, required this.merchant});
@override
State<MerchantDetailPage> createState() => _MerchantDetailPageState();
}
class _MerchantDetailPageState extends State<MerchantDetailPage> {
final ScrollController _scrollController = ScrollController();
final List<GlobalKey> _productSectionKeys = [];
String _selectedCategoryId = "";
// Sample data
final List<ProductCategory> categories = [
ProductCategory(id: "1", name: "Makanan", icon: "🍽️", productCount: 8),
ProductCategory(id: "2", name: "Minuman", icon: "🥤", productCount: 6),
ProductCategory(id: "3", name: "Snack", icon: "🍿", productCount: 5),
ProductCategory(id: "4", name: "Es Krim", icon: "🍦", productCount: 4),
ProductCategory(id: "5", name: "Paket", icon: "📦", productCount: 3),
];
final List<Product> products = [
// Makanan
Product(
id: "1",
name: "Nasi Gudeg",
description: "Gudeg khas Yogyakarta dengan ayam dan telur",
price: 25000,
categoryId: "1",
imageUrl: "https://via.placeholder.com/150",
isAvailable: true,
rating: 4.5,
soldCount: 50,
),
Product(
id: "2",
name: "Soto Ayam",
description: "Soto ayam kuning dengan nasi dan kerupuk",
price: 18000,
categoryId: "1",
imageUrl: "https://via.placeholder.com/150",
isAvailable: true,
rating: 4.3,
soldCount: 75,
),
Product(
id: "3",
name: "Gado-gado",
description: "Gado-gado segar dengan bumbu kacang",
price: 15000,
categoryId: "1",
imageUrl: "https://via.placeholder.com/150",
isAvailable: false,
rating: 4.2,
soldCount: 30,
),
// Minuman
Product(
id: "4",
name: "Es Teh Manis",
description: "Es teh manis segar",
price: 5000,
categoryId: "2",
imageUrl: "https://via.placeholder.com/150",
isAvailable: true,
rating: 4.0,
soldCount: 120,
),
Product(
id: "5",
name: "Jus Jeruk",
description: "Jus jeruk segar tanpa gula tambahan",
price: 12000,
categoryId: "2",
imageUrl: "https://via.placeholder.com/150",
isAvailable: true,
rating: 4.4,
soldCount: 45,
),
// Snack
Product(
id: "6",
name: "Keripik Pisang",
description: "Keripik pisang renyah dan manis",
price: 8000,
categoryId: "3",
imageUrl: "https://via.placeholder.com/150",
isAvailable: true,
rating: 4.1,
soldCount: 25,
),
];
@override
void initState() {
super.initState();
_selectedCategoryId = categories.isNotEmpty ? categories.first.id : "";
// Initialize keys for each category
for (int i = 0; i < categories.length; i++) {
_productSectionKeys.add(GlobalKey());
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _scrollToCategory(String categoryId) {
setState(() {
_selectedCategoryId = categoryId;
});
final categoryIndex = categories.indexWhere((cat) => cat.id == categoryId);
if (categoryIndex >= 0 && categoryIndex < _productSectionKeys.length) {
final key = _productSectionKeys[categoryIndex];
final context = key.currentContext;
if (context != null) {
Future.delayed(Duration(milliseconds: 100), () {
if (mounted) {
Scrollable.ensureVisible(
context,
duration: Duration(milliseconds: 500),
curve: Curves.easeInOut,
alignment: 0.1,
);
}
});
}
}
}
List<Product> _getProductsByCategory(String categoryId) {
return products
.where((product) => product.categoryId == categoryId)
.toList();
}
String _formatCurrency(int amount) {
return amount.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]}.',
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColor.background,
body: CustomScrollView(
controller: _scrollController,
slivers: [
// App Bar with merchant info
SliverAppBar(
expandedHeight: 280,
pinned: true,
backgroundColor: AppColor.primary,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: AppColor.textWhite),
onPressed: () => Navigator.of(context).pop(),
),
actions: [
IconButton(
icon: Icon(Icons.share, color: AppColor.textWhite),
onPressed: () {},
),
IconButton(
icon: Icon(Icons.favorite_border, color: AppColor.textWhite),
onPressed: () {},
),
],
flexibleSpace: FlexibleSpaceBar(background: _buildMerchantHeader()),
),
// Categories (will be pinned)
SliverPersistentHeader(
pinned: true,
delegate: _SliverCategoryDelegate(
categories: categories,
selectedCategoryId: _selectedCategoryId,
onCategoryTap: _scrollToCategory,
),
),
// Product sections by category
...categories.map((category) {
final categoryProducts = _getProductsByCategory(category.id);
final categoryIndex = categories.indexOf(category);
return SliverToBoxAdapter(
key: _productSectionKeys[categoryIndex],
child: _buildProductSection(category, categoryProducts),
);
}).toList(),
],
),
);
}
Widget _buildMerchantHeader() {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppColor.primary, AppColor.primary.withOpacity(0.8)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Stack(
children: [
// Background decoration
Positioned(
right: 20,
top: 60,
child: Opacity(
opacity: 0.1,
child: Icon(Icons.store, size: 100, color: AppColor.textWhite),
),
),
// Content
Positioned(
left: 20,
right: 20,
bottom: 20,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Merchant avatar
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppColor.black.withOpacity(0.2),
blurRadius: 8,
offset: Offset(0, 4),
),
],
),
child: Center(
child: Text(
_getCategoryIcon(widget.merchant.category),
style: TextStyle(fontSize: 36),
),
),
),
SizedBox(height: 16),
// Merchant name
Text(
widget.merchant.name,
style: AppStyle.h4.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 4),
// Category and status
Row(
children: [
Text(
widget.merchant.category,
style: AppStyle.md.copyWith(
color: AppColor.textWhite.withOpacity(0.9),
),
),
SizedBox(width: 12),
Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: widget.merchant.isOpen
? AppColor.success
: AppColor.error,
borderRadius: BorderRadius.circular(6),
),
child: Text(
widget.merchant.isOpen ? "BUKA" : "TUTUP",
style: AppStyle.xs.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.bold,
),
),
),
],
),
SizedBox(height: 8),
// Rating
Row(
children: [
Icon(Icons.star, color: AppColor.warning, size: 16),
SizedBox(width: 4),
Text(
"${widget.merchant.rating}",
style: AppStyle.md.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w600,
),
),
SizedBox(width: 8),
Text(
"${products.length} produk",
style: AppStyle.sm.copyWith(
color: AppColor.textWhite.withOpacity(0.8),
),
),
],
),
],
),
),
],
),
);
}
Widget _buildProductSection(
ProductCategory category,
List<Product> categoryProducts,
) {
return Container(
margin: EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section header
Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Row(
children: [
Text(category.icon, style: AppStyle.h5),
SizedBox(width: 8),
Text(
category.name,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SizedBox(width: 8),
Text(
"(${categoryProducts.length})",
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
),
],
),
),
// Products grid
if (categoryProducts.isEmpty)
_buildEmptyCategory()
else
GridView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.75,
),
itemCount: categoryProducts.length,
itemBuilder: (context, index) {
return _buildProductCard(categoryProducts[index]);
},
),
],
),
);
}
Widget _buildProductCard(Product product) {
return Container(
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppColor.black.withOpacity(0.06),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Product image
Expanded(
flex: 3,
child: Stack(
children: [
Container(
width: double.infinity,
decoration: BoxDecoration(
color: AppColor.backgroundLight,
borderRadius: BorderRadius.vertical(
top: Radius.circular(12),
),
),
child: Center(
child: Icon(
Icons.fastfood,
size: 40,
color: AppColor.textLight,
),
),
),
// Availability overlay
if (!product.isAvailable)
Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: AppColor.black.withOpacity(0.6),
borderRadius: BorderRadius.vertical(
top: Radius.circular(12),
),
),
child: Center(
child: Text(
"HABIS",
style: AppStyle.sm.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.bold,
),
),
),
),
// Rating badge
Positioned(
top: 8,
right: 8,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.star, size: 12, color: AppColor.warning),
SizedBox(width: 2),
Text(
"${product.rating}",
style: AppStyle.xs.copyWith(
color: AppColor.textPrimary,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
),
),
// Product info
Expanded(
flex: 2,
child: Padding(
padding: EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Spacer(),
// Price and sold count
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Rp ${_formatCurrency(product.price)}",
style: AppStyle.md.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.primary,
),
),
Text(
"${product.soldCount} terjual",
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
),
),
],
),
],
),
),
),
],
),
);
}
Widget _buildEmptyCategory() {
return Container(
margin: EdgeInsets.symmetric(horizontal: 16),
padding: EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.borderLight),
),
child: Center(
child: Column(
children: [
Icon(
Icons.inventory_2_outlined,
size: 48,
color: AppColor.textLight,
),
SizedBox(height: 12),
Text(
"Belum ada produk",
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
),
],
),
),
);
}
String _getCategoryIcon(String category) {
switch (category.toLowerCase()) {
case 'food & beverage':
return '🍽️';
case 'electronics':
return '📱';
case 'healthcare':
return '⚕️';
case 'clothing':
return '👕';
case 'automotive':
return '🚗';
case 'retail':
return '🛍️';
case 'beauty':
return '💄';
case 'service':
return '🔧';
default:
return '🏪';
}
}
}
// Custom delegate for pinned category bar
class _SliverCategoryDelegate extends SliverPersistentHeaderDelegate {
final List<ProductCategory> categories;
final String selectedCategoryId;
final Function(String) onCategoryTap;
_SliverCategoryDelegate({
required this.categories,
required this.selectedCategoryId,
required this.onCategoryTap,
});
@override
double get minExtent => 60;
@override
double get maxExtent => 60;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return Container(
height: 60,
decoration: BoxDecoration(
color: AppColor.surface,
border: Border(
bottom: BorderSide(color: AppColor.borderLight, width: 1),
),
),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
final isSelected = category.id == selectedCategoryId;
return GestureDetector(
onTap: () => onCategoryTap(category.id),
child: Container(
margin: EdgeInsets.only(right: 12),
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? AppColor.primary : AppColor.backgroundLight,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected ? AppColor.primary : AppColor.border,
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(category.icon, style: AppStyle.md),
SizedBox(width: 8),
Text(
category.name,
style: AppStyle.md.copyWith(
color: isSelected
? AppColor.textWhite
: AppColor.textPrimary,
fontWeight: FontWeight.w600,
),
),
SizedBox(width: 4),
Container(
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isSelected
? AppColor.textWhite.withOpacity(0.2)
: AppColor.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Text(
"${category.productCount}",
style: AppStyle.xs.copyWith(
color: isSelected
? AppColor.textWhite
: AppColor.primary,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
},
),
);
}
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
return oldDelegate is _SliverCategoryDelegate &&
(oldDelegate.selectedCategoryId != selectedCategoryId ||
oldDelegate.categories != categories);
}
}

View File

@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import '../../../../common/theme/theme.dart';
class EmptyMerchantCard extends StatelessWidget {
final String? title;
final String? subtitle;
final IconData? icon;
const EmptyMerchantCard({super.key, this.title, this.subtitle, this.icon});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: AppColor.primaryWithOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
icon ?? Icons.search_off,
size: 64,
color: AppColor.primary,
),
),
const SizedBox(height: 24),
Text(
title ?? 'No merchants found',
style: AppStyle.xl.copyWith(
color: AppColor.textPrimary,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
subtitle ??
'Try adjusting your search terms or check back later for new merchants',
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: AppColor.primaryWithOpacity(0.1),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: AppColor.primary.withOpacity(0.2),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.refresh, size: 18, color: AppColor.primary),
const SizedBox(width: 8),
Text(
'Refresh',
style: AppStyle.sm.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
);
}
}

View File

@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import '../../../../common/theme/theme.dart';
import '../../../components/image/image.dart';
import '../merchant_page.dart';
class MerchantCard extends StatelessWidget {
final MerchantModel merchant;
final VoidCallback? onTap;
const MerchantCard({super.key, required this.merchant, this.onTap});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.borderLight, width: 0.5),
boxShadow: [
BoxShadow(
color: AppColor.textSecondary.withOpacity(0.08),
blurRadius: 6,
offset: const Offset(0, 1),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Column(
children: [
// Merchant Image/Icon - Made more compact
Container(
width: double.infinity,
height: 100,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
),
child: ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
child: Image.network(
merchant.imageUrl,
width: double.infinity,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColor.primary,
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
),
);
},
errorBuilder: (context, error, stackTrace) {
return ImagePlaceholder(
width: double.infinity,
height: 100,
showBorderRadius: false,
);
},
),
),
),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Merchant Name - Flexible to prevent overflow
Flexible(
child: Text(
merchant.name,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 3),
// Category - More compact
Text(
merchant.category,
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
// Rating and Status - Compact layout
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Rating
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.star_rounded,
size: 14,
color: AppColor.warning,
),
const SizedBox(width: 3),
Text(
merchant.rating.toStringAsFixed(1),
style: AppStyle.xs.copyWith(
fontWeight: FontWeight.w500,
color: AppColor.textPrimary,
),
),
],
),
),
// Status Badge - More compact
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: merchant.isOpen
? AppColor.successWithOpacity(0.12)
: AppColor.errorWithOpacity(0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text(
merchant.isOpen ? 'Open' : 'Closed',
style: TextStyle(
fontSize: 10,
color: merchant.isOpen
? AppColor.success
: AppColor.error,
fontWeight: FontWeight.w600,
height: 1,
),
),
),
],
),
],
),
),
],
),
),
),
);
}
}

View File

@ -180,7 +180,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
const SizedBox(height: 12),
TextButton(
onPressed: () {},
onPressed: () => context.router.push(const MainRoute()),
child: Text(
'Lewati tahap ini',
style: AppStyle.md.copyWith(

View File

@ -0,0 +1,823 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../../../common/theme/theme.dart';
import '../../reward_page.dart';
@RoutePage()
class ProductRedeemPage extends StatefulWidget {
final Product product;
final Merchant merchant;
final PointCard pointCard;
const ProductRedeemPage({
super.key,
required this.product,
required this.merchant,
required this.pointCard,
});
@override
State<ProductRedeemPage> createState() => _ProductRedeemPageState();
}
class _ProductRedeemPageState extends State<ProductRedeemPage>
with TickerProviderStateMixin {
bool _isProcessing = false;
bool _showSuccess = false;
String _redeemCode = '';
late AnimationController _pulseController;
late AnimationController _successController;
late Animation<double> _pulseAnimation;
late Animation<double> _successAnimation;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
duration: Duration(milliseconds: 1500),
vsync: this,
);
_successController = AnimationController(
duration: Duration(milliseconds: 800),
vsync: this,
);
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.1).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
);
_successAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _successController, curve: Curves.elasticOut),
);
_pulseController.repeat(reverse: true);
}
@override
void dispose() {
_pulseController.dispose();
_successController.dispose();
super.dispose();
}
bool get canRedeem =>
widget.pointCard.availablePoints >= widget.product.pointsRequired;
int get pointsShortage =>
widget.product.pointsRequired - widget.pointCard.availablePoints;
Future<void> _processRedeem() async {
setState(() {
_isProcessing = true;
});
// Simulate API call
await Future.delayed(Duration(seconds: 2));
// Generate mock redeem code
_redeemCode =
'RDM${DateTime.now().millisecondsSinceEpoch.toString().substring(8)}';
setState(() {
_isProcessing = false;
_showSuccess = true;
});
_pulseController.stop();
_successController.forward();
// Auto dismiss after 3 seconds
Future.delayed(Duration(seconds: 3), () {
if (mounted) {
Navigator.pop(
context,
true,
); // Return true to indicate successful redemption
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColor.white,
body: CustomScrollView(
slivers: [
// Custom App Bar with product image
SliverAppBar(
expandedHeight: 280,
floating: false,
pinned: true,
backgroundColor: AppColor.white,
elevation: 0,
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColor.primary.withOpacity(0.1),
AppColor.backgroundLight,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Center(
child: AnimatedBuilder(
animation: _pulseAnimation,
builder: (context, child) {
return Transform.scale(
scale: _isProcessing ? _pulseAnimation.value : 1.0,
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: AppColor.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppColor.primary.withOpacity(0.2),
blurRadius: 20,
offset: Offset(0, 8),
),
],
),
child: Center(
child: Text(
widget.product.image,
style: TextStyle(fontSize: 48),
),
),
),
);
},
),
),
),
),
),
// Product Details
SliverToBoxAdapter(
child: _showSuccess ? _buildSuccessView() : _buildProductDetails(),
),
],
),
);
}
Widget _buildProductDetails() {
return Padding(
padding: EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Merchant Info
Container(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(widget.merchant.logo, style: TextStyle(fontSize: 16)),
SizedBox(width: 6),
Text(
widget.merchant.name,
style: AppStyle.sm.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w600,
),
),
],
),
),
SizedBox(height: 16),
// Product Name & Popular Badge
Row(
children: [
Expanded(
child: Text(
widget.product.name,
style: AppStyle.h4.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.textPrimary,
),
),
),
if (widget.product.isPopular)
Container(
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColor.warning,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.local_fire_department,
color: AppColor.white,
size: 14,
),
SizedBox(width: 4),
Text(
"Popular",
style: AppStyle.xs.copyWith(
color: AppColor.white,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
SizedBox(height: 8),
// Description
Text(
widget.product.fullDescription ?? widget.product.description,
style: AppStyle.md.copyWith(
color: AppColor.textSecondary,
height: 1.5,
),
),
SizedBox(height: 24),
// Points Required Card
Container(
width: double.infinity,
padding: EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColor.primary.withOpacity(0.1),
AppColor.primary.withOpacity(0.05),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColor.primary.withOpacity(0.3)),
),
child: Column(
children: [
Row(
children: [
Icon(
Icons.stars_rounded,
color: AppColor.warning,
size: 24,
),
SizedBox(width: 8),
Text(
"Points Required",
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
],
),
SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"${widget.product.pointsRequired}",
style: AppStyle.h3.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.primary,
),
),
Text(
"Points needed",
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"${widget.pointCard.availablePoints}",
style: AppStyle.h3.copyWith(
fontWeight: FontWeight.w700,
color: canRedeem
? AppColor.success
: AppColor.error,
),
),
Text(
"Your points",
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
),
],
),
],
),
],
),
),
SizedBox(height: 20),
// Insufficient Points Warning
if (!canRedeem)
Container(
width: double.infinity,
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.error.withOpacity(0.3)),
),
child: Row(
children: [
Icon(
Icons.warning_amber_rounded,
color: AppColor.error,
size: 24,
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Insufficient Points",
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.error,
),
),
Text(
"You need ${pointsShortage} more points to redeem this item",
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
),
],
),
),
],
),
),
SizedBox(height: 24),
// Terms & Conditions
_buildInfoSection(
"Terms & Conditions",
widget.product.termsAndConditions ??
"• Valid for single use only\n• Cannot be combined with other offers\n• No cash value\n• Subject to availability\n• Valid at participating locations only",
),
SizedBox(height: 16),
// Validity
_buildInfoSection(
"Validity",
widget.product.validUntil ??
"Valid until: 30 days from redemption date",
),
SizedBox(height: 32),
// Redeem Button
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: (_isProcessing || !canRedeem) ? null : _processRedeem,
style: ElevatedButton.styleFrom(
backgroundColor: canRedeem
? AppColor.primary
: AppColor.textLight,
foregroundColor: AppColor.white,
elevation: canRedeem ? 4 : 0,
shadowColor: AppColor.primary.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: _isProcessing
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
AppColor.white,
),
),
),
SizedBox(width: 12),
Text(
"Processing...",
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.white,
),
),
],
)
: Text(
canRedeem ? "Redeem Now" : "Insufficient Points",
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.white,
),
),
),
),
SizedBox(height: 20),
// Alternative action for insufficient points
if (!canRedeem)
SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton(
onPressed: () {
// Navigate to earn points page or show earn points options
_showEarnPointsOptions();
},
style: OutlinedButton.styleFrom(
foregroundColor: AppColor.primary,
side: BorderSide(color: AppColor.primary, width: 2),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_circle_outline, size: 20),
SizedBox(width: 8),
Text(
"Earn More Points",
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.primary,
),
),
],
),
),
),
SizedBox(height: 40),
],
),
);
}
Widget _buildSuccessView() {
return AnimatedBuilder(
animation: _successAnimation,
builder: (context, child) {
return Transform.scale(
scale: _successAnimation.value,
child: Padding(
padding: EdgeInsets.all(20),
child: Column(
children: [
SizedBox(height: 40),
// Success Icon
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: AppColor.success,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppColor.success.withOpacity(0.3),
blurRadius: 20,
offset: Offset(0, 8),
),
],
),
child: Icon(
Icons.check_rounded,
color: AppColor.white,
size: 48,
),
),
SizedBox(height: 24),
// Success Message
Text(
"Redemption Successful!",
style: AppStyle.h4.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.success,
),
textAlign: TextAlign.center,
),
SizedBox(height: 12),
Text(
"Your ${widget.product.name} is ready!",
style: AppStyle.lg.copyWith(color: AppColor.textSecondary),
textAlign: TextAlign.center,
),
SizedBox(height: 32),
// Redeem Code Card
Container(
width: double.infinity,
padding: EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColor.success, width: 2),
boxShadow: [
BoxShadow(
color: AppColor.success.withOpacity(0.1),
blurRadius: 12,
offset: Offset(0, 4),
),
],
),
child: Column(
children: [
Text(
"Your Redeem Code",
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textSecondary,
),
),
SizedBox(height: 12),
Container(
padding: EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
decoration: BoxDecoration(
color: AppColor.backgroundLight,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_redeemCode,
style: AppStyle.h5.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.primary,
letterSpacing: 2,
),
),
),
SizedBox(height: 12),
Text(
"Show this code at ${widget.merchant.name}",
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
textAlign: TextAlign.center,
),
],
),
),
SizedBox(height: 24),
// Points Deducted Info
Container(
width: double.infinity,
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.stars, color: AppColor.primary, size: 20),
SizedBox(width: 8),
Text(
"${widget.product.pointsRequired} points deducted",
style: AppStyle.md.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
),
);
},
);
}
Widget _buildInfoSection(String title, String content) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
SizedBox(height: 8),
Container(
width: double.infinity,
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.border),
),
child: Text(
content,
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
height: 1.6,
),
),
),
],
);
}
void _showEarnPointsOptions() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => Container(
height: MediaQuery.of(context).size.height * 0.6,
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
children: [
// Handle
Container(
margin: EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppColor.textLight.withOpacity(0.5),
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Earn More Points",
style: AppStyle.h5.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.textPrimary,
),
),
SizedBox(height: 8),
Text(
"You need ${pointsShortage} more points to redeem this item",
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
),
SizedBox(height: 24),
// Earn Points Options
_buildEarnOption(
"🛍️",
"Shop & Earn",
"Earn 1 point for every \$1 spent",
"Earn up to 500 points per day",
),
_buildEarnOption(
"🎯",
"Complete Missions",
"Daily and weekly challenges",
"Earn 100-1000 points per mission",
),
_buildEarnOption(
"👥",
"Refer Friends",
"Invite friends to join",
"Earn 500 points per referral",
),
SizedBox(height: 20),
// Close Button
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: AppColor.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
"Got it",
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.white,
),
),
),
),
],
),
),
],
),
),
);
}
Widget _buildEarnOption(
String icon,
String title,
String description,
String reward,
) {
return Container(
margin: EdgeInsets.only(bottom: 12),
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.backgroundLight,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.border),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(12),
),
child: Center(child: Text(icon, style: TextStyle(fontSize: 24))),
),
SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
Text(
description,
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
Text(
reward,
style: AppStyle.xs.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w600,
),
),
],
),
),
Icon(Icons.arrow_forward_ios, color: AppColor.textLight, size: 16),
],
),
);
}
}

View File

@ -0,0 +1,918 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../common/theme/theme.dart';
import '../../router/app_router.gr.dart';
// Models
class PointCard {
final int totalPoints;
final int usedPoints;
final String membershipLevel;
PointCard({
required this.totalPoints,
required this.usedPoints,
required this.membershipLevel,
});
int get availablePoints => totalPoints - usedPoints;
}
class Merchant {
final String id;
final String name;
final String logo;
final List<Category> categories;
Merchant({
required this.id,
required this.name,
required this.logo,
required this.categories,
});
}
class Category {
final String id;
final String name;
final String icon;
final List<Product> products;
Category({
required this.id,
required this.name,
required this.icon,
required this.products,
});
}
class Product {
final String id;
final String name;
final String image;
final int pointsRequired;
final String description;
final bool isPopular;
final String? fullDescription;
final String? validUntil;
final String? termsAndConditions;
Product({
required this.id,
required this.name,
required this.image,
required this.pointsRequired,
required this.description,
this.isPopular = false,
this.fullDescription,
this.validUntil,
this.termsAndConditions,
});
}
@RoutePage()
class RewardPage extends StatefulWidget {
const RewardPage({super.key});
@override
State<RewardPage> createState() => _RewardPageState();
}
class _RewardPageState extends State<RewardPage> {
final ScrollController _scrollController = ScrollController();
// Sample data
final PointCard pointCard = PointCard(
totalPoints: 15000,
usedPoints: 3500,
membershipLevel: "Gold Member",
);
final List<Merchant> merchants = [
Merchant(
id: "1",
name: "Starbucks",
logo: "",
categories: [
Category(
id: "c1",
name: "Beverages",
icon: "🥤",
products: [
Product(
id: "p1",
name: "Americano",
image: "",
pointsRequired: 2500,
description: "Classic black coffee",
isPopular: true,
),
Product(
id: "p2",
name: "Cappuccino",
image: "",
pointsRequired: 3000,
description: "Espresso with steamed milk",
),
Product(
id: "p3",
name: "Frappuccino",
image: "🥤",
pointsRequired: 4000,
description: "Iced blended coffee",
),
],
),
Category(
id: "c2",
name: "Food",
icon: "🍰",
products: [
Product(
id: "p4",
name: "Croissant",
image: "🥐",
pointsRequired: 1500,
description: "Buttery pastry",
),
Product(
id: "p5",
name: "Sandwich",
image: "🥪",
pointsRequired: 3500,
description: "Fresh deli sandwich",
),
],
),
],
),
Merchant(
id: "2",
name: "McDonald's",
logo: "🍔",
categories: [
Category(
id: "c3",
name: "Burgers",
icon: "🍔",
products: [
Product(
id: "p6",
name: "Big Mac",
image: "🍔",
pointsRequired: 5000,
description: "Iconic double burger",
isPopular: true,
),
Product(
id: "p7",
name: "Quarter Pounder",
image: "🍔",
pointsRequired: 4500,
description: "Fresh beef quarter pound",
),
],
),
Category(
id: "c4",
name: "Drinks",
icon: "🥤",
products: [
Product(
id: "p8",
name: "Coca Cola",
image: "🥤",
pointsRequired: 1000,
description: "Ice cold soda",
),
Product(
id: "p9",
name: "McCafe Coffee",
image: "",
pointsRequired:
20000, // High points untuk demonstrasi insufficient
description: "Premium coffee blend",
),
],
),
],
),
];
Merchant? selectedMerchant;
Map<String, GlobalKey> categoryKeys = {};
String? selectedMerchantId; // Track merchant ID untuk detect changes
String? activeCategoryId; // Track active category
@override
void initState() {
super.initState();
selectedMerchant = merchants.first;
selectedMerchantId = selectedMerchant?.id;
activeCategoryId =
selectedMerchant?.categories.first.id; // Set first category as active
_initializeCategoryKeys();
}
void _initializeCategoryKeys() {
categoryKeys.clear();
for (var category in selectedMerchant?.categories ?? []) {
categoryKeys[category.id] = GlobalKey();
}
}
void _scrollToCategory(String categoryId) {
// Update active category state FIRST
setState(() {
activeCategoryId = categoryId;
});
// Tunggu sampai widget selesai rebuild dan keys ter-attach
Future.delayed(Duration(milliseconds: 50), () {
final key = categoryKeys[categoryId];
if (key?.currentContext != null) {
print("Scrolling to category: $categoryId"); // Debug log
try {
Scrollable.ensureVisible(
key!.currentContext!,
duration: Duration(milliseconds: 500),
curve: Curves.easeInOut,
alignment: 0.1, // Position kategori sedikit dari atas
);
} catch (e) {
print("Error scrolling to category: $e");
}
} else {
print("Key not found for category: $categoryId"); // Debug log
print("Available keys: ${categoryKeys.keys.toList()}"); // Debug log
// Retry dengan delay lebih lama jika belum ready
Future.delayed(Duration(milliseconds: 200), () {
final retryKey = categoryKeys[categoryId];
if (retryKey?.currentContext != null) {
Scrollable.ensureVisible(
retryKey!.currentContext!,
duration: Duration(milliseconds: 500),
curve: Curves.easeInOut,
alignment: 0.1,
);
}
});
}
});
}
void _onMerchantChanged(Merchant merchant) {
if (selectedMerchantId == merchant.id)
return; // Prevent unnecessary rebuilds
setState(() {
selectedMerchant = merchant;
selectedMerchantId = merchant.id;
// Reset active category to first category of new merchant
activeCategoryId = merchant.categories.first.id;
});
// Reinitialize category keys for new merchant - immediate call
_initializeCategoryKeys();
// Auto scroll to top when merchant changes
if (_scrollController.hasClients) {
_scrollController.animateTo(
0,
duration: Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColor.background,
body: NestedScrollView(
controller: _scrollController,
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
// Sticky AppBar
SliverAppBar(
elevation: 0,
backgroundColor: AppColor.white,
title: Text(
"Rewards",
style: AppStyle.h5.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
centerTitle: true,
floating: false,
pinned: true, // Made sticky
snap: false,
),
// Point Card Section
SliverToBoxAdapter(child: _buildPointCard()),
// Merchant Selection
SliverToBoxAdapter(child: _buildMerchantSelection()),
// Sticky Category Tabs
SliverPersistentHeader(
pinned: true,
key: ValueKey(
'${selectedMerchant?.id}_$activeCategoryId',
), // Force rebuild with key
delegate: _StickyHeaderDelegate(
child: _buildCategoryTabs(),
height: 66,
merchantId: selectedMerchant?.id ?? '', // Pass merchant ID
activeCategoryId: activeCategoryId, // Pass active category ID
),
),
];
},
body: selectedMerchant == null
? SizedBox.shrink()
: ListView.builder(
padding: EdgeInsets.only(top: 16),
itemCount: selectedMerchant!.categories.length,
itemBuilder: (context, index) {
final category = selectedMerchant!.categories[index];
return _buildCategorySection(category);
},
),
),
);
}
Widget _buildPointCard() {
return Container(
margin: EdgeInsets.all(16),
padding: EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppColor.primary, AppColor.primaryDark],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColor.primary.withOpacity(0.3),
blurRadius: 12,
offset: Offset(0, 6),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pointCard.membershipLevel,
style: AppStyle.sm.copyWith(
color: AppColor.textWhite.withOpacity(0.9),
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 4),
Text(
"${pointCard.availablePoints}",
style: AppStyle.h2.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w700,
),
),
Text(
"Available Points",
style: AppStyle.sm.copyWith(
color: AppColor.textWhite.withOpacity(0.9),
),
),
],
),
Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColor.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.stars_rounded,
color: AppColor.textWhite,
size: 32,
),
),
],
),
SizedBox(height: 16),
Container(
height: 8,
decoration: BoxDecoration(
color: AppColor.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(4),
),
child: FractionallySizedBox(
widthFactor:
(pointCard.totalPoints - pointCard.usedPoints) /
pointCard.totalPoints,
alignment: Alignment.centerLeft,
child: Container(
decoration: BoxDecoration(
color: AppColor.textWhite,
borderRadius: BorderRadius.circular(4),
),
),
),
),
SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Used: ${pointCard.usedPoints}",
style: AppStyle.xs.copyWith(
color: AppColor.textWhite.withOpacity(0.8),
),
),
Text(
"Total: ${pointCard.totalPoints}",
style: AppStyle.xs.copyWith(
color: AppColor.textWhite.withOpacity(0.8),
),
),
],
),
],
),
);
}
Widget _buildMerchantSelection() {
return Container(
margin: EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Select Merchant",
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
SizedBox(height: 12),
Container(
height: 80,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: merchants.length,
itemBuilder: (context, index) {
final merchant = merchants[index];
final isSelected = selectedMerchant?.id == merchant.id;
return GestureDetector(
onTap: () => _onMerchantChanged(merchant),
child: Container(
width: 80,
margin: EdgeInsets.only(right: 12),
decoration: BoxDecoration(
color: isSelected ? AppColor.primary : AppColor.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? AppColor.primary : AppColor.border,
width: 2,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(merchant.logo, style: TextStyle(fontSize: 24)),
SizedBox(height: 4),
Text(
merchant.name,
style: AppStyle.xs.copyWith(
color: isSelected
? AppColor.textWhite
: AppColor.textPrimary,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
},
),
),
],
),
);
}
Widget _buildCategoryTabs() {
if (selectedMerchant == null) return SizedBox.shrink();
return Container(
color: AppColor.background, // Background untuk sticky header
padding: EdgeInsets.symmetric(vertical: 8),
child: Container(
height: 50,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 16),
itemCount: selectedMerchant!.categories.length,
itemBuilder: (context, index) {
final category = selectedMerchant!.categories[index];
final isActive =
activeCategoryId ==
category.id; // Check if this category is active
return GestureDetector(
onTap: () => _scrollToCategory(category.id),
child: Container(
margin: EdgeInsets.only(right: 12),
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
color: isActive
? AppColor.primary
: AppColor.white, // Change background when active
borderRadius: BorderRadius.circular(25),
border: Border.all(
color: isActive
? AppColor.primary
: AppColor.border, // Change border when active
width: 2,
),
boxShadow: [
BoxShadow(
color: AppColor.textLight.withOpacity(0.1),
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(category.icon, style: TextStyle(fontSize: 16)),
SizedBox(width: 6),
Text(
category.name,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.w500,
color: isActive
? AppColor.textWhite
: AppColor
.textPrimary, // Change text color when active
),
),
],
),
),
);
},
),
),
);
}
Widget _buildCategorySection(Category category) {
return Container(
key: categoryKeys[category.id],
margin: EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Text(category.icon, style: TextStyle(fontSize: 20)),
SizedBox(width: 8),
Text(
category.name,
style: AppStyle.xl.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
],
),
),
SizedBox(height: 12),
// Fixed height yang lebih besar untuk menghindari overflow
Container(
height: 240, // Increased from 200 to 240
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 16),
itemCount: category.products.length,
itemBuilder: (context, index) {
final product = category.products[index];
return _buildProductCard(product);
},
),
),
],
),
);
}
Widget _buildProductCard(Product product) {
final canRedeem = pointCard.availablePoints >= product.pointsRequired;
final pointsShortage = canRedeem
? 0
: product.pointsRequired - pointCard.availablePoints;
return Container(
width: 160,
margin: EdgeInsets.only(right: 12),
child: Stack(
children: [
Container(
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColor.textLight.withOpacity(0.15),
blurRadius: 8,
offset: Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Product Image
Container(
height: 90, // Reduced from 100 to give more space below
width: double.infinity,
decoration: BoxDecoration(
color: AppColor.backgroundLight,
borderRadius: BorderRadius.vertical(
top: Radius.circular(16),
),
),
child: Stack(
children: [
Center(
child: Text(
product.image,
style: TextStyle(fontSize: 36), // Reduced from 40
),
),
if (product.isPopular)
Positioned(
top: 6, // Adjusted position
right: 6,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 6, // Reduced padding
vertical: 3,
),
decoration: BoxDecoration(
color: AppColor.warning,
borderRadius: BorderRadius.circular(10),
),
child: Text(
"Popular",
style: AppStyle.xs.copyWith(
color: AppColor.white,
fontWeight: FontWeight.w600,
fontSize: 10, // Smaller font
),
),
),
),
],
),
),
// Product Info - Made more flexible
Expanded(
child: Padding(
padding: EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
color: canRedeem
? AppColor.textPrimary
: AppColor.textLight,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 4),
Expanded(
child: Text(
product.description,
style: AppStyle.xs.copyWith(
color: canRedeem
? AppColor.textSecondary
: AppColor.textLight,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
SizedBox(height: 8),
Row(
children: [
Icon(
Icons.stars,
size: 14, // Reduced size
color: canRedeem
? AppColor.warning
: AppColor.textLight,
),
SizedBox(width: 4),
Expanded(
child: Text(
"${product.pointsRequired}",
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.w600,
color: canRedeem
? AppColor.primary
: AppColor.textLight,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
SizedBox(height: 8),
SizedBox(
width: double.infinity,
height: 32, // Reduced from 36
child: ElevatedButton(
onPressed: canRedeem
? () => _redeemProduct(product)
: null,
style: ElevatedButton.styleFrom(
backgroundColor: canRedeem
? AppColor.primary
: AppColor.textLight,
foregroundColor: AppColor.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: FittedBox(
child: Text(
canRedeem ? "Redeem" : "Insufficient",
style: AppStyle.xs.copyWith(
// Changed to xs
fontWeight: FontWeight.w600,
color: AppColor.white,
),
),
),
),
),
],
),
),
),
],
),
),
// Overlay untuk insufficient points
if (!canRedeem)
Container(
decoration: BoxDecoration(
color: AppColor.textLight.withOpacity(0.7),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
), // Reduced padding
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.lock_outline,
color: AppColor.textSecondary,
size: 20, // Reduced size
),
SizedBox(height: 4),
Text(
"Need ${pointsShortage}",
style: AppStyle.xs.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textSecondary,
fontSize: 10, // Smaller font
),
textAlign: TextAlign.center,
),
Text(
"more points",
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontSize: 10, // Smaller font
),
textAlign: TextAlign.center,
),
],
),
),
),
),
],
),
);
}
void _redeemProduct(Product product) {
context.router.push(
ProductRedeemRoute(
product: product,
merchant: selectedMerchant!,
pointCard: pointCard,
),
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
}
// Custom SliverPersistentHeaderDelegate untuk sticky category tabs
class _StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
final Widget child;
final double height;
final String merchantId; // Add merchant ID to track changes
final String? activeCategoryId; // Add active category to track changes
_StickyHeaderDelegate({
required this.child,
required this.height,
required this.merchantId, // Track merchant changes
required this.activeCategoryId, // Track category changes
});
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return child;
}
@override
double get maxExtent => height;
@override
double get minExtent => height;
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
// Always rebuild when merchant OR active category changes
if (oldDelegate is _StickyHeaderDelegate) {
bool merchantChanged = oldDelegate.merchantId != merchantId;
bool categoryChanged = oldDelegate.activeCategoryId != activeCategoryId;
print(
"shouldRebuild - Merchant changed: $merchantChanged, Category changed: $categoryChanged",
);
print(
"Old merchant: ${oldDelegate.merchantId}, New merchant: $merchantId",
);
print(
"Old category: ${oldDelegate.activeCategoryId}, New category: $activeCategoryId",
);
return merchantChanged || categoryChanged;
}
return true;
}
}

View File

@ -27,5 +27,17 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: ProfileRoute.page),
],
),
// Merchant
AutoRoute(page: MerchantRoute.page),
AutoRoute(page: MerchantDetailRoute.page),
// Reward
AutoRoute(page: RewardRoute.page),
AutoRoute(page: ProductRedeemRoute.page),
// Draw
AutoRoute(page: DrawRoute.page),
AutoRoute(page: DrawDetailRoute.page),
];
}

View File

@ -9,130 +9,245 @@
// coverage:ignore-file
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:auto_route/auto_route.dart' as _i12;
import 'package:enaklo/presentation/pages/auth/login/login_page.dart' as _i2;
import 'package:enaklo/presentation/pages/auth/otp/otp_page.dart' as _i6;
import 'package:enaklo/presentation/pages/auth/pin/pin_page.dart' as _i7;
import 'package:auto_route/auto_route.dart' as _i18;
import 'package:enaklo/presentation/pages/auth/login/login_page.dart' as _i4;
import 'package:enaklo/presentation/pages/auth/otp/otp_page.dart' as _i10;
import 'package:enaklo/presentation/pages/auth/pin/pin_page.dart' as _i11;
import 'package:enaklo/presentation/pages/auth/register/register_page.dart'
as _i9;
import 'package:enaklo/presentation/pages/main/main_page.dart' as _i3;
import 'package:enaklo/presentation/pages/main/pages/home/home_page.dart'
as _i14;
import 'package:enaklo/presentation/pages/draw/draw_page.dart' as _i2;
import 'package:enaklo/presentation/pages/draw/pages/draw_detail/draw_detail_page.dart'
as _i1;
import 'package:enaklo/presentation/pages/main/main_page.dart' as _i5;
import 'package:enaklo/presentation/pages/main/pages/home/home_page.dart'
as _i3;
import 'package:enaklo/presentation/pages/main/pages/order/order_page.dart'
as _i5;
as _i9;
import 'package:enaklo/presentation/pages/main/pages/profile/profile_page.dart'
as _i8;
as _i13;
import 'package:enaklo/presentation/pages/main/pages/voucher/voucher_page.dart'
as _i11;
as _i17;
import 'package:enaklo/presentation/pages/merchant/merchant_page.dart' as _i7;
import 'package:enaklo/presentation/pages/merchant/pages/merchant_detail/merchant_detail_page.dart'
as _i6;
import 'package:enaklo/presentation/pages/onboarding/onboarding_page.dart'
as _i4;
import 'package:enaklo/presentation/pages/splash/splash_page.dart' as _i10;
import 'package:flutter/material.dart' as _i13;
as _i8;
import 'package:enaklo/presentation/pages/reward/pages/product_redeem/product_redeem_page.dart'
as _i12;
import 'package:enaklo/presentation/pages/reward/reward_page.dart' as _i15;
import 'package:enaklo/presentation/pages/splash/splash_page.dart' as _i16;
import 'package:flutter/material.dart' as _i19;
/// generated route for
/// [_i1.HomePage]
class HomeRoute extends _i12.PageRouteInfo<void> {
const HomeRoute({List<_i12.PageRouteInfo>? children})
/// [_i1.DrawDetailPage]
class DrawDetailRoute extends _i18.PageRouteInfo<DrawDetailRouteArgs> {
DrawDetailRoute({
_i19.Key? key,
required _i2.DrawEvent drawEvent,
List<_i18.PageRouteInfo>? children,
}) : super(
DrawDetailRoute.name,
args: DrawDetailRouteArgs(key: key, drawEvent: drawEvent),
initialChildren: children,
);
static const String name = 'DrawDetailRoute';
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
final args = data.argsAs<DrawDetailRouteArgs>();
return _i1.DrawDetailPage(key: args.key, drawEvent: args.drawEvent);
},
);
}
class DrawDetailRouteArgs {
const DrawDetailRouteArgs({this.key, required this.drawEvent});
final _i19.Key? key;
final _i2.DrawEvent drawEvent;
@override
String toString() {
return 'DrawDetailRouteArgs{key: $key, drawEvent: $drawEvent}';
}
}
/// generated route for
/// [_i2.DrawPage]
class DrawRoute extends _i18.PageRouteInfo<void> {
const DrawRoute({List<_i18.PageRouteInfo>? children})
: super(DrawRoute.name, initialChildren: children);
static const String name = 'DrawRoute';
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
return const _i2.DrawPage();
},
);
}
/// generated route for
/// [_i3.HomePage]
class HomeRoute extends _i18.PageRouteInfo<void> {
const HomeRoute({List<_i18.PageRouteInfo>? children})
: super(HomeRoute.name, initialChildren: children);
static const String name = 'HomeRoute';
static _i12.PageInfo page = _i12.PageInfo(
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
return const _i1.HomePage();
return const _i3.HomePage();
},
);
}
/// generated route for
/// [_i2.LoginPage]
class LoginRoute extends _i12.PageRouteInfo<void> {
const LoginRoute({List<_i12.PageRouteInfo>? children})
/// [_i4.LoginPage]
class LoginRoute extends _i18.PageRouteInfo<void> {
const LoginRoute({List<_i18.PageRouteInfo>? children})
: super(LoginRoute.name, initialChildren: children);
static const String name = 'LoginRoute';
static _i12.PageInfo page = _i12.PageInfo(
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
return const _i2.LoginPage();
return const _i4.LoginPage();
},
);
}
/// generated route for
/// [_i3.MainPage]
class MainRoute extends _i12.PageRouteInfo<void> {
const MainRoute({List<_i12.PageRouteInfo>? children})
/// [_i5.MainPage]
class MainRoute extends _i18.PageRouteInfo<void> {
const MainRoute({List<_i18.PageRouteInfo>? children})
: super(MainRoute.name, initialChildren: children);
static const String name = 'MainRoute';
static _i12.PageInfo page = _i12.PageInfo(
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
return const _i3.MainPage();
return const _i5.MainPage();
},
);
}
/// generated route for
/// [_i4.OnboardingPage]
class OnboardingRoute extends _i12.PageRouteInfo<void> {
const OnboardingRoute({List<_i12.PageRouteInfo>? children})
/// [_i6.MerchantDetailPage]
class MerchantDetailRoute extends _i18.PageRouteInfo<MerchantDetailRouteArgs> {
MerchantDetailRoute({
_i19.Key? key,
required _i7.MerchantModel merchant,
List<_i18.PageRouteInfo>? children,
}) : super(
MerchantDetailRoute.name,
args: MerchantDetailRouteArgs(key: key, merchant: merchant),
initialChildren: children,
);
static const String name = 'MerchantDetailRoute';
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
final args = data.argsAs<MerchantDetailRouteArgs>();
return _i6.MerchantDetailPage(key: args.key, merchant: args.merchant);
},
);
}
class MerchantDetailRouteArgs {
const MerchantDetailRouteArgs({this.key, required this.merchant});
final _i19.Key? key;
final _i7.MerchantModel merchant;
@override
String toString() {
return 'MerchantDetailRouteArgs{key: $key, merchant: $merchant}';
}
}
/// generated route for
/// [_i7.MerchantPage]
class MerchantRoute extends _i18.PageRouteInfo<void> {
const MerchantRoute({List<_i18.PageRouteInfo>? children})
: super(MerchantRoute.name, initialChildren: children);
static const String name = 'MerchantRoute';
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
return const _i7.MerchantPage();
},
);
}
/// generated route for
/// [_i8.OnboardingPage]
class OnboardingRoute extends _i18.PageRouteInfo<void> {
const OnboardingRoute({List<_i18.PageRouteInfo>? children})
: super(OnboardingRoute.name, initialChildren: children);
static const String name = 'OnboardingRoute';
static _i12.PageInfo page = _i12.PageInfo(
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
return const _i4.OnboardingPage();
return const _i8.OnboardingPage();
},
);
}
/// generated route for
/// [_i5.OrderPage]
class OrderRoute extends _i12.PageRouteInfo<void> {
const OrderRoute({List<_i12.PageRouteInfo>? children})
/// [_i9.OrderPage]
class OrderRoute extends _i18.PageRouteInfo<void> {
const OrderRoute({List<_i18.PageRouteInfo>? children})
: super(OrderRoute.name, initialChildren: children);
static const String name = 'OrderRoute';
static _i12.PageInfo page = _i12.PageInfo(
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
return const _i5.OrderPage();
return const _i9.OrderPage();
},
);
}
/// generated route for
/// [_i6.OtpPage]
class OtpRoute extends _i12.PageRouteInfo<void> {
const OtpRoute({List<_i12.PageRouteInfo>? children})
/// [_i10.OtpPage]
class OtpRoute extends _i18.PageRouteInfo<void> {
const OtpRoute({List<_i18.PageRouteInfo>? children})
: super(OtpRoute.name, initialChildren: children);
static const String name = 'OtpRoute';
static _i12.PageInfo page = _i12.PageInfo(
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
return const _i6.OtpPage();
return const _i10.OtpPage();
},
);
}
/// generated route for
/// [_i7.PinPage]
class PinRoute extends _i12.PageRouteInfo<PinRouteArgs> {
/// [_i11.PinPage]
class PinRoute extends _i18.PageRouteInfo<PinRouteArgs> {
PinRoute({
_i13.Key? key,
_i19.Key? key,
bool isCreatePin = true,
String? title,
List<_i12.PageRouteInfo>? children,
List<_i18.PageRouteInfo>? children,
}) : super(
PinRoute.name,
args: PinRouteArgs(key: key, isCreatePin: isCreatePin, title: title),
@ -141,13 +256,13 @@ class PinRoute extends _i12.PageRouteInfo<PinRouteArgs> {
static const String name = 'PinRoute';
static _i12.PageInfo page = _i12.PageInfo(
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
final args = data.argsAs<PinRouteArgs>(
orElse: () => const PinRouteArgs(),
);
return _i7.PinPage(
return _i11.PinPage(
key: args.key,
isCreatePin: args.isCreatePin,
title: args.title,
@ -159,7 +274,7 @@ class PinRoute extends _i12.PageRouteInfo<PinRouteArgs> {
class PinRouteArgs {
const PinRouteArgs({this.key, this.isCreatePin = true, this.title});
final _i13.Key? key;
final _i19.Key? key;
final bool isCreatePin;
@ -172,65 +287,139 @@ class PinRouteArgs {
}
/// generated route for
/// [_i8.ProfilePage]
class ProfileRoute extends _i12.PageRouteInfo<void> {
const ProfileRoute({List<_i12.PageRouteInfo>? children})
/// [_i12.ProductRedeemPage]
class ProductRedeemRoute extends _i18.PageRouteInfo<ProductRedeemRouteArgs> {
ProductRedeemRoute({
_i19.Key? key,
required _i15.Product product,
required _i15.Merchant merchant,
required _i15.PointCard pointCard,
List<_i18.PageRouteInfo>? children,
}) : super(
ProductRedeemRoute.name,
args: ProductRedeemRouteArgs(
key: key,
product: product,
merchant: merchant,
pointCard: pointCard,
),
initialChildren: children,
);
static const String name = 'ProductRedeemRoute';
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
final args = data.argsAs<ProductRedeemRouteArgs>();
return _i12.ProductRedeemPage(
key: args.key,
product: args.product,
merchant: args.merchant,
pointCard: args.pointCard,
);
},
);
}
class ProductRedeemRouteArgs {
const ProductRedeemRouteArgs({
this.key,
required this.product,
required this.merchant,
required this.pointCard,
});
final _i19.Key? key;
final _i15.Product product;
final _i15.Merchant merchant;
final _i15.PointCard pointCard;
@override
String toString() {
return 'ProductRedeemRouteArgs{key: $key, product: $product, merchant: $merchant, pointCard: $pointCard}';
}
}
/// generated route for
/// [_i13.ProfilePage]
class ProfileRoute extends _i18.PageRouteInfo<void> {
const ProfileRoute({List<_i18.PageRouteInfo>? children})
: super(ProfileRoute.name, initialChildren: children);
static const String name = 'ProfileRoute';
static _i12.PageInfo page = _i12.PageInfo(
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
return const _i8.ProfilePage();
return const _i13.ProfilePage();
},
);
}
/// generated route for
/// [_i9.RegisterPage]
class RegisterRoute extends _i12.PageRouteInfo<void> {
const RegisterRoute({List<_i12.PageRouteInfo>? children})
/// [_i14.RegisterPage]
class RegisterRoute extends _i18.PageRouteInfo<void> {
const RegisterRoute({List<_i18.PageRouteInfo>? children})
: super(RegisterRoute.name, initialChildren: children);
static const String name = 'RegisterRoute';
static _i12.PageInfo page = _i12.PageInfo(
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
return const _i9.RegisterPage();
return const _i14.RegisterPage();
},
);
}
/// generated route for
/// [_i10.SplashPage]
class SplashRoute extends _i12.PageRouteInfo<void> {
const SplashRoute({List<_i12.PageRouteInfo>? children})
/// [_i15.RewardPage]
class RewardRoute extends _i18.PageRouteInfo<void> {
const RewardRoute({List<_i18.PageRouteInfo>? children})
: super(RewardRoute.name, initialChildren: children);
static const String name = 'RewardRoute';
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
return const _i15.RewardPage();
},
);
}
/// generated route for
/// [_i16.SplashPage]
class SplashRoute extends _i18.PageRouteInfo<void> {
const SplashRoute({List<_i18.PageRouteInfo>? children})
: super(SplashRoute.name, initialChildren: children);
static const String name = 'SplashRoute';
static _i12.PageInfo page = _i12.PageInfo(
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
return const _i10.SplashPage();
return const _i16.SplashPage();
},
);
}
/// generated route for
/// [_i11.VoucherPage]
class VoucherRoute extends _i12.PageRouteInfo<void> {
const VoucherRoute({List<_i12.PageRouteInfo>? children})
/// [_i17.VoucherPage]
class VoucherRoute extends _i18.PageRouteInfo<void> {
const VoucherRoute({List<_i18.PageRouteInfo>? children})
: super(VoucherRoute.name, initialChildren: children);
static const String name = 'VoucherRoute';
static _i12.PageInfo page = _i12.PageInfo(
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
return const _i11.VoucherPage();
return const _i17.VoucherPage();
},
);
}