256 lines
7.7 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|