feat: integrate enakgame and voucher
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s
This commit is contained in:
@@ -1,60 +1,50 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../common/extension/extension.dart';
|
||||
import '../../../common/theme/theme.dart';
|
||||
import '../../../sample/reward_sample_data.dart';
|
||||
import '../../../common/ui/painter/dashed_line_painter.dart';
|
||||
import '../../../domain/wallet/wallet.dart';
|
||||
import '../../components/toast/flushbar.dart';
|
||||
import '../wallet/wallet_ui.dart';
|
||||
|
||||
/// Reward aktif dengan sisa masa berlaku segini dianggap segera berakhir.
|
||||
const int rewardExpiringSoonDays = 7;
|
||||
/// Label jenis voucher (§9.1).
|
||||
String voucherTypeLabel(VoucherType type, int faceValue) => switch (type) {
|
||||
VoucherType.fixedValue => 'Potongan ${faceValue.currencyFormatRp}',
|
||||
VoucherType.percentage => 'Potongan persen',
|
||||
VoucherType.freeItem => 'Gratis item',
|
||||
VoucherType.merchantBenefit => 'Benefit merchant',
|
||||
VoucherType.unknown => 'Voucher',
|
||||
};
|
||||
|
||||
extension RewardStatusUi on RewardStatus {
|
||||
/// "senilai Rp 20.000". `null` untuk potongan nominal, karena nilainya sudah
|
||||
/// ada di [voucherTypeLabel], dan bila server tidak mengirim nilai.
|
||||
String? voucherFaceValueLabel(VoucherType type, int faceValue) =>
|
||||
type == VoucherType.fixedValue || faceValue <= 0
|
||||
? null
|
||||
: 'senilai ${faceValue.currencyFormatRp}';
|
||||
|
||||
extension VoucherRedemptionStatusUi on VoucherRedemptionStatus {
|
||||
String get label => switch (this) {
|
||||
RewardStatus.active => 'Aktif',
|
||||
RewardStatus.used => 'Terpakai',
|
||||
RewardStatus.expired => 'Kedaluwarsa',
|
||||
VoucherRedemptionStatus.completed => 'Siap dipakai',
|
||||
VoucherRedemptionStatus.pending => 'Sedang diproses',
|
||||
VoucherRedemptionStatus.failed => 'Gagal',
|
||||
VoucherRedemptionStatus.unknown => '',
|
||||
};
|
||||
|
||||
Color get color => switch (this) {
|
||||
RewardStatus.active => AppColor.success,
|
||||
RewardStatus.used => AppColor.textSecondary,
|
||||
RewardStatus.expired => AppColor.error,
|
||||
VoucherRedemptionStatus.completed => AppColor.success,
|
||||
VoucherRedemptionStatus.pending => AppColor.warning,
|
||||
VoucherRedemptionStatus.failed => AppColor.error,
|
||||
VoucherRedemptionStatus.unknown => AppColor.textSecondary,
|
||||
};
|
||||
}
|
||||
|
||||
/// Tanggal pendek: "1 Okt 2026".
|
||||
String formatRewardDate(DateTime date) =>
|
||||
DateFormat('d MMM yyyy', 'id_ID').format(date);
|
||||
class VoucherRedemptionStatusBadge extends StatelessWidget {
|
||||
const VoucherRedemptionStatusBadge({super.key, required this.status});
|
||||
|
||||
/// Sisa hari kalender sampai [Reward.validUntil]; 0 berarti berakhir hari ini.
|
||||
int rewardDaysLeft(Reward reward) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final end = DateTime(
|
||||
reward.validUntil.year,
|
||||
reward.validUntil.month,
|
||||
reward.validUntil.day,
|
||||
);
|
||||
return end.difference(today).inDays;
|
||||
}
|
||||
|
||||
bool isRewardExpiringSoon(Reward reward) =>
|
||||
reward.status == RewardStatus.active &&
|
||||
rewardDaysLeft(reward) <= rewardExpiringSoonDays;
|
||||
|
||||
/// "Berakhir hari ini" / "Berakhir 3 hari lagi" / "Berlaku s/d 25 Okt 2026".
|
||||
String rewardValidityLabel(Reward reward) {
|
||||
final daysLeft = rewardDaysLeft(reward);
|
||||
if (daysLeft <= 0) return 'Berakhir hari ini';
|
||||
if (daysLeft <= rewardExpiringSoonDays) {
|
||||
return 'Berakhir $daysLeft hari lagi';
|
||||
}
|
||||
return 'Berlaku s/d ${formatRewardDate(reward.validUntil)}';
|
||||
}
|
||||
|
||||
class RewardStatusBadge extends StatelessWidget {
|
||||
const RewardStatusBadge({super.key, required this.status});
|
||||
|
||||
final RewardStatus status;
|
||||
final VoucherRedemptionStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -75,13 +65,62 @@ class RewardStatusBadge extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Emoji reward; abu-abu untuk reward yang sudah tidak bisa dipakai.
|
||||
class RewardEmoji extends StatelessWidget {
|
||||
const RewardEmoji({super.key, required this.reward, required this.size});
|
||||
/// Pesan untuk penolakan tukar voucher (§9.2). `cause` dari server berbahasa
|
||||
/// Inggris dan tidak ditampilkan mentah. [reloadCatalog] menandai voucher yang
|
||||
/// sudah tidak tersedia, sehingga katalog perlu dimuat ulang.
|
||||
({String message, bool reloadCatalog}) describeVoucherRedeemFailure(
|
||||
WalletFailure failure,
|
||||
) {
|
||||
final cause = failure.maybeMap(
|
||||
rejected: (value) => value.cause.toLowerCase(),
|
||||
orElse: () => null,
|
||||
);
|
||||
if (cause == null) {
|
||||
return (message: walletFailureMessage(failure), reloadCatalog: false);
|
||||
}
|
||||
|
||||
final Reward reward;
|
||||
if (cause.contains('not enough enakpoint')) {
|
||||
return (message: 'EnakPoint kamu kurang.', reloadCatalog: false);
|
||||
}
|
||||
if (cause.contains('out of stock')) {
|
||||
return (message: 'Voucher sudah habis.', reloadCatalog: true);
|
||||
}
|
||||
if (cause.contains('per customer')) {
|
||||
return (
|
||||
message: 'Kamu sudah mencapai batas penukaran voucher ini.',
|
||||
reloadCatalog: false,
|
||||
);
|
||||
}
|
||||
if (cause.contains('customer is not active')) {
|
||||
return (message: 'Akun tidak aktif.', reloadCatalog: false);
|
||||
}
|
||||
if (cause.contains('not available') ||
|
||||
cause.contains('cannot be redeemed yet') ||
|
||||
cause.contains('has ended')) {
|
||||
return (message: 'Voucher tidak tersedia.', reloadCatalog: true);
|
||||
}
|
||||
// Termasuk Idempotency-Key yang terpakai untuk voucher lain (bug di app).
|
||||
return (
|
||||
message: 'Penukaran voucher tidak bisa diproses.',
|
||||
reloadCatalog: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Gambar voucher dari `image_url`; ikon tiket bila kosong.
|
||||
class VoucherImage extends StatelessWidget {
|
||||
const VoucherImage({
|
||||
super.key,
|
||||
required this.url,
|
||||
required this.size,
|
||||
this.dimmed = false,
|
||||
});
|
||||
|
||||
final String? url;
|
||||
final double size;
|
||||
|
||||
/// Abu-abu untuk voucher yang tidak bisa ditukar / dipakai.
|
||||
final bool dimmed;
|
||||
|
||||
static const ColorFilter _greyscale = ColorFilter.matrix([
|
||||
0.2126, 0.7152, 0.0722, 0, 0, //
|
||||
0.2126, 0.7152, 0.0722, 0, 0, //
|
||||
@@ -91,8 +130,126 @@ class RewardEmoji extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final emoji = Text(reward.emoji, style: TextStyle(fontSize: size));
|
||||
if (reward.status == RewardStatus.active) return emoji;
|
||||
return ColorFiltered(colorFilter: _greyscale, child: emoji);
|
||||
final fallback = SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Icon(
|
||||
Icons.confirmation_number_outlined,
|
||||
size: size * 0.7,
|
||||
color: dimmed ? AppColor.textLight : AppColor.primary,
|
||||
),
|
||||
);
|
||||
|
||||
final image = url == null
|
||||
? fallback
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: url!,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, _) => SizedBox(width: size, height: size),
|
||||
errorWidget: (_, _, _) => fallback,
|
||||
),
|
||||
);
|
||||
|
||||
if (!dimmed || url == null) return image;
|
||||
return ColorFiltered(colorFilter: _greyscale, child: image);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kode voucher yang bisa disalin. Customer menunjukkan layar ini ke kasir.
|
||||
class VoucherCodeBox extends StatelessWidget {
|
||||
const VoucherCodeBox({super.key, required this.code});
|
||||
|
||||
final String code;
|
||||
|
||||
Future<void> _copy(BuildContext context) async {
|
||||
await Clipboard.setData(ClipboardData(text: code));
|
||||
if (!context.mounted) return;
|
||||
AppFlushbar.showSuccess(context, 'Kode voucher disalin');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 8, 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColor.border),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Tunjukkan kode ini ke kasir',
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
CustomPaint(
|
||||
size: const Size(double.infinity, 1),
|
||||
painter: DashedLinePainter(),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: SelectableText(
|
||||
code,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.xl.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _copy(context),
|
||||
tooltip: 'Salin kode',
|
||||
icon: const Icon(
|
||||
Icons.copy_rounded,
|
||||
size: 20,
|
||||
color: AppColor.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Catatan untuk voucher tanpa kode (`code` null): customer menunjukkan layar
|
||||
/// voucher ke kasir.
|
||||
class VoucherNoCodeNote extends StatelessWidget {
|
||||
const VoucherNoCodeNote({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.successWithOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(AppValue.borderRadius),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.storefront_outlined,
|
||||
size: 20,
|
||||
color: AppColor.success,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Voucher ini tidak memakai kode. Tunjukkan layar ini ke kasir.',
|
||||
style: AppStyle.sm,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user