feat: integrate enakgame and voucher
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s

This commit is contained in:
efrilm
2026-10-10 02:33:16 +07:00
parent e5515d5871
commit 38850b281f
104 changed files with 7122 additions and 8063 deletions
+208 -51
View File
@@ -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,
),
),
],
),
);
}
}