Files
enaklo-flutter/lib/presentation/pages/reward/reward_ui.dart
T
efrilm 38850b281f
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s
feat: integrate enakgame and voucher
2026-10-10 02:33:16 +07:00

256 lines
7.7 KiB
Dart

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../common/extension/extension.dart';
import '../../../common/theme/theme.dart';
import '../../../common/ui/painter/dashed_line_painter.dart';
import '../../../domain/wallet/wallet.dart';
import '../../components/toast/flushbar.dart';
import '../wallet/wallet_ui.dart';
/// 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',
};
/// "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) {
VoucherRedemptionStatus.completed => 'Siap dipakai',
VoucherRedemptionStatus.pending => 'Sedang diproses',
VoucherRedemptionStatus.failed => 'Gagal',
VoucherRedemptionStatus.unknown => '',
};
Color get color => switch (this) {
VoucherRedemptionStatus.completed => AppColor.success,
VoucherRedemptionStatus.pending => AppColor.warning,
VoucherRedemptionStatus.failed => AppColor.error,
VoucherRedemptionStatus.unknown => AppColor.textSecondary,
};
}
class VoucherRedemptionStatusBadge extends StatelessWidget {
const VoucherRedemptionStatusBadge({super.key, required this.status});
final VoucherRedemptionStatus status;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: status.color.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
status.label,
style: AppStyle.xs.copyWith(
color: status.color,
fontWeight: FontWeight.w700,
),
),
);
}
}
/// 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);
}
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, //
0.2126, 0.7152, 0.0722, 0, 0, //
0, 0, 0, 0.5, 0, //
]);
@override
Widget build(BuildContext context) {
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,
),
),
],
),
);
}
}