EnakPoint & EnakCoin wallet, PIN, outlet, order, community, reward

- Wallet EnakPoint & EnakCoin: beranda, riwayat, saldo kedaluwarsa, kode
  bayar, tukar EnakCoin, transfer
- PIN (buat, ganti, lupa), outlet, riwayat order, game spin
- Halaman community, webview, coming soon, dan ikon menu beranda baru
- Redesign Reward Saya: kartu tiket, filter chip, detail kode + QR
- Hapus customer point loader, menu, dan checkout lama
- Base URL ke enaklo-pos-api.altru.id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-10-01 01:43:22 +07:00
co-authored by Claude Opus 5.5
parent 5954510b93
commit f5e3075205
253 changed files with 47212 additions and 17437 deletions
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import '../../../common/api/api_failure.dart';
import '../../../common/extension/extension.dart';
import '../../../common/theme/theme.dart';
import '../../../domain/wallet/wallet.dart';
import '../../components/assets/assets.gen.dart';
/// Pesan ramah untuk customer. [rejectedMessage] dipakai untuk penolakan
/// aturan bisnis (`304`), karena `cause` dari server tidak boleh tampil mentah.
String walletFailureMessage(WalletFailure failure, {String? rejectedMessage}) {
return failure.map(
serverError: (value) => value.failure.maybeMap(
connectionError: (_) => 'Tidak ada koneksi internet. Coba lagi.',
connectionTimeout: (_) => 'Koneksi lambat. Coba lagi.',
orElse: () => 'Terjadi kesalahan, coba lagi',
),
unexpectedError: (_) => 'Terjadi kesalahan, coba lagi',
unauthorized: (_) => 'Sesi kamu sudah berakhir. Silakan masuk lagi.',
invalidRequest: (_) => 'Permintaan tidak dapat diproses. Coba lagi.',
rejected: (_) => rejectedMessage ?? 'Permintaan tidak dapat diproses.',
notFound: (_) => 'Data tidak ditemukan',
tooManyRequests: (value) =>
'Terlalu sering meminta kode. Coba lagi dalam '
'${value.retryAfterSeconds} detik.',
internalError: (_) => 'Terjadi kesalahan, coba lagi',
pinNotSet: (_) => 'Kamu belum membuat PIN.',
pinInvalid: (value) => value.remainingAttempts == null
? 'PIN salah.'
: 'PIN salah, sisa ${value.remainingAttempts} percobaan.',
pinLocked: (value) => value.lockedUntil == null
? 'PIN terkunci. Silakan gunakan Lupa PIN.'
: 'PIN terkunci sampai ${formatWibTime(value.lockedUntil!)}.',
transferBlocked: (value) => value.transferBlockedUntil == null
? 'Transfer sementara ditahan karena PIN baru direset.'
: 'Transfer bisa dilakukan lagi pada '
'${formatWibDateTime(value.transferBlockedUntil!)}.',
);
}
/// Jam WIB, dengan tanggal bila bukan hari ini: "14:30 WIB" /
/// "1 Okt 2026 14:30 WIB".
String formatWibTime(DateTime time) {
final wib = time.toWib;
final today = DateTime.now().toWib;
final sameDay =
wib.year == today.year &&
wib.month == today.month &&
wib.day == today.day;
return sameDay ? '${wib.toHourMinute} WIB' : formatWibDateTime(time);
}
String formatWibDateTime(DateTime time) => '${time.toWib.toShortDatetime} WIB';
/// Tanggal kalender WIB dari timestamp, mis. `expires_at`.
String formatWibDate(DateTime time) => time.toWib.toDate;
/// Nilai rupiah EnakPoint selalu "setara potongan", tidak pernah saldo atau
/// uang (§1 aturan 2).
String pointDiscountLabel(int discountValue) =>
'setara potongan ${discountValue.currencyFormatRp}';
/// Jumlah bertanda: "+875" / "−120" (tanda minus U+2212).
String formatSignedAmount(int amount) => amount >= 0
? '+${amount.thousandFormat}'
: '−${amount.abs().thousandFormat}';
Color signedAmountColor(int amount) =>
amount >= 0 ? AppColor.success : AppColor.error;
String formatCountdown(Duration duration) {
final seconds = duration.inSeconds < 0 ? 0 : duration.inSeconds;
final m = (seconds ~/ 60).toString().padLeft(2, '0');
final s = (seconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
/// Hanya digit, untuk nomor HP dan jumlah.
String digitsOnly(String value) => value.replaceAll(RegExp(r'[^0-9]'), '');
/// Tanggal pendek untuk chip filter: "1 Sep 2026".
String formatShortDate(DateTime date) =>
DateFormat('d MMM yyyy', 'id_ID').format(date);
/// Menampilkan jumlah dengan pemisah ribuan saat diketik: "10000" → "10.000".
/// Baca nilainya dengan [digitsOnly].
class ThousandsInputFormatter extends TextInputFormatter {
static const int _maxDigits = 12;
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = digitsOnly(newValue.text);
if (digits.isEmpty) return const TextEditingValue();
if (digits.length > _maxDigits) return oldValue;
final text = int.parse(digits).thousandFormat;
return TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
}
}
/// Ikon 3D EnakPoint / EnakCoin.
class WalletCurrencyIcon extends StatelessWidget {
const WalletCurrencyIcon({super.key, required this.currency, this.size = 24});
final WalletCurrency currency;
final double size;
@override
Widget build(BuildContext context) {
final asset = currency == WalletCurrency.point
? Assets.icons.enakpointIcon
: Assets.icons.enakcoinIcon;
// Aset aslinya 512–1024px; decode seukuran tampilan saja.
final cacheSize = (size * MediaQuery.devicePixelRatioOf(context)).round();
return asset.image(
width: size,
height: size,
fit: BoxFit.contain,
cacheWidth: cacheSize,
);
}
}