Compare commits
9
Commits
8f0a5d3fb5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84cae9e789 | ||
|
|
38850b281f | ||
|
|
e5515d5871 | ||
|
|
cb5e5e70bf | ||
|
|
671dd3a5d0 | ||
|
|
66d59ee59e | ||
|
|
57fb56303a | ||
|
|
2560c1890b | ||
|
|
a09fda0585 |
@@ -92,7 +92,7 @@ jobs:
|
||||
cat > $RUNNER_TEMP/signing.xcconfig << 'XCCONFIG'
|
||||
CODE_SIGN_STYLE = Manual
|
||||
CODE_SIGN_IDENTITY = iPhone Distribution
|
||||
PROVISIONING_PROFILE_SPECIFIER = Enaklo App Store
|
||||
PROVISIONING_PROFILE_SPECIFIER = Enaklo Customer
|
||||
CODE_SIGNING_REQUIRED = YES
|
||||
XCCONFIG
|
||||
|
||||
|
||||
@@ -43,3 +43,10 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Signing credentials — never commit these
|
||||
*.p12
|
||||
*.pfx
|
||||
*.cer
|
||||
*.mobileprovision
|
||||
*.certSigningRequest
|
||||
|
||||
@@ -0,0 +1,799 @@
|
||||
# Integrasi Mobile App Customer: EnakPoint, EnakCoin, EnakGame & Voucher
|
||||
|
||||
**Untuk:** tim aplikasi mobile customer · **Base URL:** `/api/v1` · **Per:** 8 Okt 2026
|
||||
|
||||
Kamu mengerjakan aplikasi mobile untuk **customer** (bukan kasir, bukan backoffice).
|
||||
Tugasmu: membangun fitur loyalitas di aplikasi, yaitu saldo EnakPoint & EnakCoin,
|
||||
PIN, tukar, transfer, voucher, dan pintu masuk ke game EnakGame. Semuanya memakai API
|
||||
backend yang sudah jadi dan dijelaskan di dokumen ini. Jangan mengarang endpoint,
|
||||
field, atau aturan yang tidak tertulis di sini; kalau ada yang kurang jelas, tanyakan
|
||||
dulu.
|
||||
|
||||
Dokumen ini menggantikan `mobile-customer-enakpoint.md`, `integration-enakpoint.md`,
|
||||
`api-enakpoint.md`, dan `enakgame-spin.md` untuk sisi aplikasi customer. Game-nya
|
||||
sendiri (Phaser) dikerjakan tim EnakGame dengan
|
||||
[`integration-enakgame.md`](./integration-enakgame.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Konteks bisnis
|
||||
|
||||
| | EnakPoint (`POINT`) | EnakCoin (`COIN`) |
|
||||
|---|---|---|
|
||||
| Didapat dari | Belanja (order lunas), tukar EnakCoin, koreksi admin | Belanja, **hadiah game**, koreksi admin |
|
||||
| Dipakai untuk | **Ditukar ke voucher** (tidak bisa membayar order) | **Main game**, ditukar ke EnakPoint |
|
||||
| Bisa dikirim ke customer lain | Ya | Ya |
|
||||
| Bisa kedaluwarsa | Ya, bila owner mengaktifkan | Ya, bila owner mengaktifkan |
|
||||
|
||||
Tidak ada lagi "token". Semua yang dulu token sekarang EnakCoin.
|
||||
|
||||
### Aturan yang wajib dipatuhi di UI
|
||||
|
||||
1. **Semua jumlah bilangan bulat.** Tidak ada desimal pada EnakPoint atau EnakCoin.
|
||||
2. **Saldo bukan uang.** Nilai rupiah EnakPoint selalu ditulis **"setara potongan
|
||||
Rp …"**, tidak pernah "saldo Rp …" atau "uang". Tidak ada tarik tunai, dan EnakPoint
|
||||
tidak bisa dipakai membayar. Jangan membangun layar bayar atau kode bayar.
|
||||
3. **PIN 6 digit wajib** untuk: tukar EnakCoin, transfer, dan **tukar EnakPoint ke
|
||||
voucher**. Main game, melihat saldo, dan riwayat tidak butuh PIN.
|
||||
4. **PIN terpisah dari password login** dan selalu dikirim sebagai **string** (supaya
|
||||
nol di depan tidak hilang). Jangan pernah menyimpan PIN di perangkat, log, atau
|
||||
analytics.
|
||||
5. **Satu akun customer = satu organisasi.** Saldo berlaku di semua outlet organisasi itu.
|
||||
6. **Waktu memakai WIB.** Tanggal kedaluwarsa berarti saldo masih bisa dipakai sampai
|
||||
23:59:59 WIB di tanggal itu.
|
||||
7. **Hadiah game ditentukan server.** Aplikasi tidak menghitung atau mengirim hadiah.
|
||||
|
||||
---
|
||||
|
||||
## 2. Koneksi ke API
|
||||
|
||||
- Base URL: `/api/v1`
|
||||
- Semua endpoint customer: header `Authorization: Bearer <token login customer>`
|
||||
- Semua jumlah di request dan response berupa integer.
|
||||
- Belum ada endpoint refresh token: bila token ditolak (§2.2, `entity` `auth_handler`),
|
||||
customer login ulang.
|
||||
|
||||
### 2.1 Registrasi customer
|
||||
|
||||
`POST /api/v1/customer-auth/register/start` menerima `organization_id` (opsional):
|
||||
|
||||
```json
|
||||
{ "phone_number": "0812…", "name": "Budi", "birth_date": "2000-01-31", "organization_id": "648b96a0-1d1d-414e-baee-37e9d6317b4e" }
|
||||
```
|
||||
|
||||
- Customer terdaftar di satu organisasi, dan saldonya berlaku di semua outlet organisasi itu.
|
||||
- Bila `organization_id` tidak dikirim dan backend hanya punya satu organisasi, customer
|
||||
otomatis masuk ke organisasi itu. Bila ada lebih dari satu, registrasi ditolak
|
||||
("organization_id is required"), jadi sebaiknya app selalu mengirimnya dari config per
|
||||
environment/brand.
|
||||
- `organization_id` yang dikirim harus ada; bila tidak, registrasi ditolak sebelum OTP dikirim.
|
||||
- Wallet customer baru belum punya baris sampai saldo pertama kali bergerak;
|
||||
`GET /customer/wallet` tetap menjawab saldo 0.
|
||||
|
||||
### 2.2 Format response
|
||||
|
||||
Sukses:
|
||||
|
||||
```json
|
||||
{ "success": true, "data": { … }, "errors": null }
|
||||
```
|
||||
|
||||
Semua contoh response di dokumen ini adalah isi `data`. Untuk daftar berhalaman, isi
|
||||
itu sendiri berbentuk `{ "data": [ … ], "pagination": { … } }`, jadi array-nya ada di
|
||||
`data.data` pada response mentah.
|
||||
|
||||
Gagal:
|
||||
|
||||
```json
|
||||
{ "success": false, "data": null, "errors": [{ "code": "304", "entity": "wallet_service", "cause": "wallet move refused: not enough EnakCoin" }] }
|
||||
```
|
||||
|
||||
| `errors[0].code` | HTTP | Arti | Yang dilakukan app |
|
||||
|---|---|---|---|
|
||||
| `303`, `310` | 400 | Request tidak lengkap / salah format | Bug di app; tampilkan pesan umum |
|
||||
| `304` | 400 | Ditolak aturan bisnis, **atau token tidak berlaku** bila `entity` = `auth_handler` | Pesan yang ramah per fitur; `cause` berbahasa Inggris, jangan tampilkan mentah. Token: login ulang |
|
||||
| `404` | 404 | Tidak ditemukan, juga untuk data milik customer lain | Tampilkan "tidak ditemukan" |
|
||||
| `429` | 429 | Minta OTP terlalu cepat | Hitung mundur sebelum boleh minta lagi |
|
||||
| `PIN_NOT_SET` | 403 | Belum punya PIN | Buka alur buat PIN (§6.2) |
|
||||
| `PIN_INVALID` | 400 | PIN salah | §6.5 |
|
||||
| `PIN_LOCKED` | 423 | PIN terkunci | §6.5 |
|
||||
| `TRANSFER_BLOCKED` | 403 | Transfer ditahan setelah reset PIN | §6.5 |
|
||||
| `900` | 500 | Error server | "Terjadi kesalahan, coba lagi" |
|
||||
|
||||
### 2.3 Idempotency-Key
|
||||
|
||||
Endpoint **tukar**, **transfer**, dan **tukar voucher** wajib header `Idempotency-Key`
|
||||
(string unik, maks. 50 karakter, mis. UUID v4; `X-Idempotency-Key` juga diterima).
|
||||
|
||||
- Buat **satu key baru saat customer menekan tombol konfirmasi**.
|
||||
- Bila request gagal karena jaringan/timeout, **kirim ulang dengan key yang sama**.
|
||||
Server mengembalikan hasil pertama dengan `"replayed": true` dan tidak memotong saldo
|
||||
dua kali.
|
||||
- Jangan pakai ulang key untuk transaksi yang berbeda; server menolaknya (`304`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Layar yang perlu dibuat
|
||||
|
||||
| Layar | Endpoint utama | Butuh PIN |
|
||||
|---|---|---|
|
||||
| Beranda wallet | `GET /customer/wallet` | – |
|
||||
| Riwayat mutasi | `GET /customer/wallet/transactions` | – |
|
||||
| Saldo akan kedaluwarsa | `GET /customer/wallet/expiring` | – |
|
||||
| Daftar outlet | `GET /customer/outlets` | – |
|
||||
| Riwayat order + detail | `GET /customer/orders`, `GET /customer/orders/:id` | – |
|
||||
| Tukar EnakCoin | `GET …/exchange/preview`, `POST /customer/wallet/exchange` | Ya |
|
||||
| Transfer | `GET …/transfer/recipient`, `POST /customer/wallet/transfer` | Ya |
|
||||
| PIN (buat, ganti, lupa) | `/customer/pin/*` | – |
|
||||
| Daftar game + webview game | `GET /customer/enakgame/games` | – |
|
||||
| Riwayat main | `GET /customer/enakgame/sessions` | – |
|
||||
| Katalog voucher | `GET /customer/vouchers` | – |
|
||||
| Tukar voucher | `POST /customer/vouchers/:id/redeem` | Ya |
|
||||
| Voucher saya | `GET /customer/vouchers/redemptions` | – |
|
||||
| (latar belakang) registrasi push | `PUT` / `DELETE /customer/devices` | – |
|
||||
|
||||
---
|
||||
|
||||
## 4. Beranda wallet, riwayat, kedaluwarsa
|
||||
|
||||
### 4.1 Beranda — `GET /customer/wallet`
|
||||
|
||||
```json
|
||||
{
|
||||
"point_balance": 12500,
|
||||
"coin_balance": 8,
|
||||
"point_value": 1,
|
||||
"point_discount_value": 12500,
|
||||
"nearest_expiring": {
|
||||
"point": { "amount": 150, "date": "2026-12-31" },
|
||||
"coin": null
|
||||
},
|
||||
"recent_transactions": [ /* sama dengan item riwayat §4.2, maksimal 5 */ ]
|
||||
}
|
||||
```
|
||||
|
||||
Tampilkan:
|
||||
- Saldo EnakPoint (`point_balance`) dengan keterangan "setara potongan Rp
|
||||
{point_discount_value}" (format ribuan Indonesia: `Rp 12.500`).
|
||||
- Saldo EnakCoin (`coin_balance`).
|
||||
- Bila `nearest_expiring.point` / `.coin` tidak `null`: banner "{amount} EnakPoint akan
|
||||
kedaluwarsa pada {date}" yang membuka layar §4.3.
|
||||
- 5 mutasi terakhir dari `recent_transactions`, dengan tautan "Lihat semua" ke §4.2.
|
||||
- Tombol aksi: Tukar EnakCoin (§7.1), Transfer (§7.2), Main game (§8), Voucher (§9).
|
||||
|
||||
Muat ulang beranda setelah setiap transaksi, saat webview game ditutup, dan saat
|
||||
menerima push (§5).
|
||||
|
||||
Field `total_points`, `points_history`, `last_updated` di response ini **deprecated**;
|
||||
jangan dipakai.
|
||||
|
||||
### 4.2 Riwayat — `GET /customer/wallet/transactions`
|
||||
|
||||
Query (semua opsional):
|
||||
|
||||
| Query | Contoh | Keterangan |
|
||||
|---|---|---|
|
||||
| `page` | `1` | Mulai dari 1 |
|
||||
| `limit` | `20` | 1–100, default 20 |
|
||||
| `currency` | `POINT` | `POINT` atau `COIN`; untuk tab EnakPoint / EnakCoin |
|
||||
| `type` | `EARN,TRANSFER_IN` | Satu atau beberapa tipe dipisah koma, untuk filter |
|
||||
| `from`, `to` | `2026-09-01` | Tanggal WIB, inklusif |
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "…",
|
||||
"currency": "POINT",
|
||||
"type": "EARN",
|
||||
"amount": 875,
|
||||
"balance_after": 12500,
|
||||
"description": "Belanja #ORD-0123 di Outlet Kemang",
|
||||
"source": { "type": "ORDER", "id": "…" },
|
||||
"outlet_id": "…",
|
||||
"group_id": null,
|
||||
"expires_at": "2026-12-31T23:59:59+07:00",
|
||||
"lots": [{ "amount": 875, "remaining": 875, "expires_at": "2026-12-31T23:59:59+07:00" }],
|
||||
"created_at": "2026-09-30T12:01:00Z"
|
||||
}
|
||||
],
|
||||
"pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 }
|
||||
}
|
||||
```
|
||||
|
||||
Aturan tampilan:
|
||||
- `amount` bertanda: positif tampil hijau dengan `+`, negatif merah dengan `−`.
|
||||
- Penambahan membawa `source`, pengurangan membawa `destination`, keduanya `{ type, id }`.
|
||||
- `description` sudah siap tampil (nama lawan transfer sudah disamarkan, nama game dan
|
||||
voucher sudah tertulis). Tampilkan apa adanya.
|
||||
- Mutasi masuk yang punya `expires_at` menampilkan "Berlaku sampai {tanggal}".
|
||||
- Infinite scroll memakai `pagination.total_pages`.
|
||||
- Riwayat tidak pernah berubah atau hilang; koreksi muncul sebagai baris baru.
|
||||
|
||||
Label tipe:
|
||||
|
||||
| `type` | Mata uang | Label | Arah |
|
||||
|---|---|---|---|
|
||||
| `EARN` | keduanya | Dari belanja | + |
|
||||
| `EARN_REVERSAL` | keduanya | Dibatalkan (order di-void/refund) | − |
|
||||
| `EXCHANGE_OUT` | EnakCoin | Ditukar ke EnakPoint | − |
|
||||
| `EXCHANGE_IN` | EnakPoint | Hasil tukar EnakCoin | + |
|
||||
| `TRANSFER_OUT` | keduanya | Transfer keluar | − |
|
||||
| `TRANSFER_IN` | keduanya | Transfer masuk | + |
|
||||
| `GAME_SPEND` | EnakCoin | Main game | − |
|
||||
| `GAME_SPEND_REFUND` | EnakCoin | Biaya main dikembalikan | + |
|
||||
| `GAME_REWARD` | EnakCoin | Hadiah game | + |
|
||||
| `REWARD_REDEEM` | EnakPoint | Ditukar ke voucher | − |
|
||||
| `REWARD_REDEEM_REFUND` | EnakPoint | Penukaran voucher dibatalkan | + |
|
||||
| `EXPIRE` | keduanya | Kedaluwarsa | − |
|
||||
| `ADJUSTMENT` | keduanya | Koreksi | + / − |
|
||||
| `MIGRATION` | keduanya | Saldo awal | + |
|
||||
|
||||
Tipe yang tidak dikenal (bila backend menambah tipe baru): tampilkan `description` dan
|
||||
arah dari tanda `amount`, tanpa label.
|
||||
|
||||
### 4.3 Akan kedaluwarsa — `GET /customer/wallet/expiring`
|
||||
|
||||
```json
|
||||
{
|
||||
"point": [
|
||||
{ "amount": 150, "date": "2026-10-31" },
|
||||
{ "amount": 200, "date": "2026-12-31" }
|
||||
],
|
||||
"coin": []
|
||||
}
|
||||
```
|
||||
|
||||
Daftar per tanggal, paling dekat di atas. Daftar kosong: tampilkan "Tidak ada saldo
|
||||
yang akan kedaluwarsa". Saldo yang kedaluwarsa hangus tanpa kompensasi.
|
||||
|
||||
### 4.4 Daftar outlet — `GET /customer/outlets`
|
||||
|
||||
Outlet aktif di organisasi customer, tempat saldo EnakPoint & EnakCoin berlaku. Urut
|
||||
berdasarkan nama.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "…",
|
||||
"name": "Gokuna Kemang",
|
||||
"address": "Jl. Kemang Raya 10",
|
||||
"earns_points": true,
|
||||
"earns_coins": false
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- `address` bisa `null`.
|
||||
- `earns_points` / `earns_coins`: belanja di outlet ini memberi EnakPoint / EnakCoin.
|
||||
- Belum ada telepon, koordinat, atau jam buka; data itu belum disimpan di backend.
|
||||
|
||||
### 4.5 Riwayat order — `GET /customer/orders` dan `GET /customer/orders/:id`
|
||||
|
||||
Order milik customer yang login di semua outlet organisasinya, terbaru di atas. Order
|
||||
hanya masuk ke sini bila kasir mengaitkannya ke customer.
|
||||
|
||||
`GET /api/v1/customer/orders?page=1&limit=20` (`limit` 1–100, default 20):
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "…",
|
||||
"order_number": "ORD-0123",
|
||||
"outlet_id": "…",
|
||||
"outlet_name": "Gokuna 1",
|
||||
"order_type": "dine_in",
|
||||
"status": "completed",
|
||||
"payment_status": "completed",
|
||||
"total_amount": 99000,
|
||||
"item_count": 2,
|
||||
"is_void": false,
|
||||
"is_refund": false,
|
||||
"points_earned": 865,
|
||||
"coins_earned": 3,
|
||||
"created_at": "2026-09-30T12:01:00Z"
|
||||
}
|
||||
],
|
||||
"pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 }
|
||||
}
|
||||
```
|
||||
|
||||
`GET /api/v1/customer/orders/{id}` mengembalikan field yang sama, ditambah:
|
||||
|
||||
```json
|
||||
{
|
||||
"table_number": "A3",
|
||||
"subtotal": 90000,
|
||||
"discount_amount": 0,
|
||||
"tax_amount": 9000,
|
||||
"refund_amount": 0,
|
||||
"items": [
|
||||
{
|
||||
"id": "…",
|
||||
"product_id": "…",
|
||||
"product_name": "Kopi Susu",
|
||||
"variant_name": "Large",
|
||||
"quantity": 2,
|
||||
"unit_price": 25000,
|
||||
"total_price": 50000,
|
||||
"refund_quantity": 0,
|
||||
"modifiers": [],
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": "…",
|
||||
"product_id": "…",
|
||||
"product_name": "Ikan Tude",
|
||||
"variant_name": null,
|
||||
"quantity": 1,
|
||||
"weight": 4.2,
|
||||
"unit_name": "ons",
|
||||
"unit_price": 4500,
|
||||
"total_price": 18900,
|
||||
"refund_quantity": 0,
|
||||
"modifiers": [],
|
||||
"status": "completed"
|
||||
}
|
||||
],
|
||||
"payments": [
|
||||
{ "id": "…", "method_name": "Cash", "method_type": "cash", "amount": 99000, "status": "completed", "refund_amount": 0, "created_at": "…" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- Order customer lain atau yang tidak ada → `404`.
|
||||
- `points_earned` / `coins_earned`: yang didapat dari order ini; 0 bila tidak ada.
|
||||
- Item timbangan membawa `weight` dan `unit_name`; tampilkan "1 × 4,2 ons".
|
||||
- Order yang `is_void` atau `is_refund` tetap tampil, beri label "Dibatalkan" /
|
||||
"Direfund".
|
||||
|
||||
---
|
||||
|
||||
## 5. Notifikasi push (FCM)
|
||||
|
||||
### 5.1 Registrasi device
|
||||
|
||||
Setelah login berhasil **dan** setiap kali FCM memberi token baru (`onTokenRefresh`):
|
||||
|
||||
`PUT /api/v1/customer/devices`
|
||||
|
||||
```json
|
||||
{ "device_id": "<id unik & stabil per instalasi>", "fcm_token": "<token FCM>", "platform": "android", "app_version": "2.4.0" }
|
||||
```
|
||||
|
||||
- `device_id` wajib, stabil untuk satu instalasi (simpan di secure storage).
|
||||
- `platform`: `android`, `ios`, atau `web`.
|
||||
- Satu token FCM hanya milik satu customer: bila customer lain login di HP yang sama,
|
||||
customer sebelumnya tidak lagi menerima notifikasi di HP itu.
|
||||
- Saat **logout**, panggil `DELETE /api/v1/customer/devices/{device_id}` sebelum
|
||||
menghapus token login.
|
||||
|
||||
Tanpa registrasi ini, customer tidak menerima push apa pun.
|
||||
|
||||
### 5.2 Tipe push
|
||||
|
||||
Semua nilai di `data` berupa string.
|
||||
|
||||
| `data.type` | Kapan | Isi `data` lain | Aksi saat di-tap |
|
||||
|---|---|---|---|
|
||||
| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` | Buka riwayat, sorot transaksi itu |
|
||||
| `WALLET_EXPIRING` | `reminder_days` hari sebelum saldo hangus | `currency`, `amount`, `expiry_date` | Buka layar kedaluwarsa (§4.3) |
|
||||
| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` | Buka riwayat |
|
||||
| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339 UTC) | Buka layar lupa PIN (§6.4) |
|
||||
|
||||
Saat app terbuka dan menerima push wallet, muat ulang beranda.
|
||||
|
||||
---
|
||||
|
||||
## 6. PIN
|
||||
|
||||
### 6.1 Kapan diminta
|
||||
|
||||
Jangan minta PIN saat registrasi. Minta saat customer **pertama kali** melakukan aksi
|
||||
yang butuh PIN (tukar, transfer, tukar voucher). Cek dengan:
|
||||
|
||||
`GET /api/v1/customer/pin/status` → `{ "has_pin": false, "locked_until": null, "transfer_blocked_until": null }`
|
||||
|
||||
Bila `has_pin: false`, arahkan ke alur buat PIN, lalu kembali ke aksi semula.
|
||||
|
||||
### 6.2 Buat PIN
|
||||
|
||||
1. `POST /api/v1/customer/pin/otp` dengan `{ "purpose": "pin_setup" }`.
|
||||
Response: `{ "purpose": "pin_setup", "otp_token": "…", "expires_at": "…" }`.
|
||||
OTP dikirim ke WhatsApp customer.
|
||||
2. Customer memasukkan kode OTP, lalu PIN dua kali.
|
||||
3. `POST /api/v1/customer/pin` dengan
|
||||
`{ "otp_token": "…", "otp_code": "123456", "pin": "482913", "confirm_pin": "482913" }`.
|
||||
Response: status PIN.
|
||||
|
||||
Validasi di app sebelum kirim (server juga memeriksa, jawab `304`):
|
||||
- Tepat 6 digit angka, dan konfirmasi sama.
|
||||
- Bukan satu digit berulang (`111111`).
|
||||
- Bukan berurutan naik/turun (`123456`, `654321`).
|
||||
- Bukan tanggal lahir customer (`DDMMYY` atau `YYMMDD`).
|
||||
|
||||
Minta OTP lagi terlalu cepat → `429`: tampilkan hitung mundur.
|
||||
|
||||
### 6.3 Ganti PIN
|
||||
|
||||
`PUT /api/v1/customer/pin` dengan `{ "old_pin": "…", "pin": "…", "confirm_pin": "…" }`.
|
||||
|
||||
### 6.4 Lupa PIN
|
||||
|
||||
1. `POST /customer/pin/otp` dengan `{ "purpose": "pin_reset" }`.
|
||||
2. `POST /customer/pin/reset` dengan `{ "otp_token", "otp_code", "pin", "confirm_pin" }`.
|
||||
|
||||
Reset juga membuka PIN yang terkunci. Setelah reset, **transfer keluar ditahan 24 jam**;
|
||||
tukar EnakCoin dan tukar voucher tetap bisa. Beri tahu customer hal ini di layar sukses.
|
||||
|
||||
### 6.5 Menangani error PIN
|
||||
|
||||
Semua endpoint yang menerima `pin` bisa menjawab error PIN. Pada error ini **`data`
|
||||
tidak `null`**:
|
||||
|
||||
```json
|
||||
{ "success": false, "data": { "code": "PIN_INVALID", "remaining_attempts": 3 }, "errors": [ … ] }
|
||||
```
|
||||
|
||||
| `data.code` | Field tambahan | Tampilan |
|
||||
|---|---|---|
|
||||
| `PIN_NOT_SET` | – | Buka alur buat PIN (§6.2) |
|
||||
| `PIN_INVALID` | `remaining_attempts` | "PIN salah, sisa {n} percobaan." Kosongkan input PIN |
|
||||
| `PIN_LOCKED` | `locked_until` | "PIN terkunci sampai {jam}." Tombol "Lupa PIN" |
|
||||
| `TRANSFER_BLOCKED` | `transfer_blocked_until` | "Transfer bisa dilakukan lagi pada {waktu}." |
|
||||
|
||||
5 kali salah berturut-turut mengunci PIN 30 menit; selama terkunci PIN yang benar pun
|
||||
ditolak. Penghitung ada di server, jadi jangan membuat penghitung sendiri di app.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tukar dan transfer
|
||||
|
||||
### 7.1 Tukar EnakCoin → EnakPoint
|
||||
|
||||
1. Customer mengetik jumlah EnakCoin. Panggil preview (debounce saat mengetik):
|
||||
|
||||
`GET /api/v1/customer/wallet/exchange/preview?coins=30`
|
||||
|
||||
```json
|
||||
{ "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true }
|
||||
```
|
||||
|
||||
- Kurs: `coin_amount` EnakCoin = `point_amount` EnakPoint. Tampilkan "10 EnakCoin =
|
||||
3 EnakPoint".
|
||||
- Bila `valid: false`, tampilkan `reason` sebagai alasan dan nonaktifkan tombol. Jumlah
|
||||
harus kelipatan `coin_amount`.
|
||||
- Tampilkan "Kamu akan mendapat {points} EnakPoint".
|
||||
|
||||
2. Konfirmasi (tukar tidak bisa dibatalkan) → minta PIN →
|
||||
|
||||
`POST /api/v1/customer/wallet/exchange` + header `Idempotency-Key`
|
||||
|
||||
```json
|
||||
{ "coins": 30, "pin": "482913" }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"group_id": "…",
|
||||
"coins": 30,
|
||||
"points": 9,
|
||||
"coin_amount": 10,
|
||||
"point_amount": 3,
|
||||
"lots": [{ "amount": 9, "expires_at": "2026-12-31T23:59:59+07:00" }],
|
||||
"coin_balance": 5,
|
||||
"point_balance": 9,
|
||||
"replayed": false
|
||||
}
|
||||
```
|
||||
|
||||
3. Layar sukses: saldo baru, dan bila `lots[].expires_at` ada, "EnakPoint ini berlaku
|
||||
sampai {tanggal}". EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin
|
||||
asalnya.
|
||||
|
||||
Jumlah yang salah ditolak sebelum PIN dicek, jadi tidak memakan jatah percobaan PIN.
|
||||
|
||||
### 7.2 Transfer
|
||||
|
||||
1. Pilih mata uang (EnakPoint / EnakCoin), isi nomor HP penerima dan jumlah.
|
||||
2. Cek penerima:
|
||||
|
||||
`GET /api/v1/customer/wallet/transfer/recipient?phone=081234561234`
|
||||
|
||||
```json
|
||||
{ "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }
|
||||
```
|
||||
|
||||
| Hasil | Tampilan |
|
||||
|---|---|
|
||||
| Sukses | "Kirim ke Bu*** Sa*** (08**-****-1234)?" |
|
||||
| `404` | "Nomor ini tidak terdaftar" |
|
||||
| `304` | "Tidak bisa mengirim ke nomor ini" (diri sendiri, akun nonaktif) |
|
||||
|
||||
3. Konfirmasi (transfer final, tidak bisa dibatalkan) → minta PIN →
|
||||
|
||||
`POST /api/v1/customer/wallet/transfer` + header `Idempotency-Key`
|
||||
|
||||
```json
|
||||
{ "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"group_id": "…",
|
||||
"currency": "POINT",
|
||||
"amount": 120,
|
||||
"recipient": { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" },
|
||||
"lots": [
|
||||
{ "amount": 100, "expires_at": "2026-12-31T23:59:59+07:00" },
|
||||
{ "amount": 20, "expires_at": null }
|
||||
],
|
||||
"balance": 30,
|
||||
"replayed": false
|
||||
}
|
||||
```
|
||||
|
||||
4. Layar sukses: saldo tersisa (`balance`). Bila ada `lots[].expires_at`, tampilkan
|
||||
"Saldo yang dikirim berlaku sampai {tanggal}" (tanggal kedaluwarsa ikut terbawa ke
|
||||
penerima).
|
||||
|
||||
Penolakan `304` yang mungkin: transfer dimatikan owner, di bawah minimal, di atas
|
||||
maksimal per transaksi, melewati batas harian (reset tengah malam WIB), saldo tidak
|
||||
cukup. Tampilkan pesan umum "Transfer tidak bisa diproses" plus alasan yang sesuai
|
||||
bila bisa dikenali. Bila kena `TRANSFER_BLOCKED`, ikuti §6.5.
|
||||
|
||||
Penerima mendapat push `WALLET_TRANSFER_IN`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Game (EnakGame)
|
||||
|
||||
Game dimainkan di **webview** yang memuat `game_url` tiap game. Pembagian tugasnya:
|
||||
aplikasi menampilkan daftar game, membuka webview, dan memberi token lewat bridge; game
|
||||
EnakGame sendiri yang memulai session, memotong EnakCoin, mengirim hasil, dan
|
||||
menampilkan hadiah ([`integration-enakgame.md`](./integration-enakgame.md)). Aplikasi
|
||||
**tidak** memanggil `POST /customer/enakgame/sessions` atau `…/complete`.
|
||||
|
||||
### 8.1 Daftar game — `GET /customer/enakgame/games`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "8a1f…",
|
||||
"slug": "spin",
|
||||
"name": "Spin Harian",
|
||||
"description": null,
|
||||
"thumbnail_url": "https://…/spin.png",
|
||||
"game_url": "https://…/spin/index.html",
|
||||
"version": "1.2.0",
|
||||
"entry_cost": 5,
|
||||
"session_ttl_seconds": 600,
|
||||
"events": [
|
||||
{ "id": "…", "name": "Ramadan 2x", "banner_url": "https://…", "multiplier": 2, "bonus": null, "end_at": "2026-10-31T16:59:59Z" }
|
||||
],
|
||||
"prizes": [ { "entry": 1, "label": "Zonk", "amount": 0 } ]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Tampilkan:
|
||||
- Kartu per game: `thumbnail_url`, `name`, biaya "{entry_cost} EnakCoin".
|
||||
- Badge event bila `events` tidak kosong: `name` atau `banner_url`, dan "berakhir
|
||||
{end_at}" (tampilkan dalam WIB).
|
||||
- Tombol Main nonaktif dengan teks "EnakCoin kurang" bila `coin_balance` (§4.1) lebih
|
||||
kecil dari `entry_cost`.
|
||||
- `prizes` hanya dipakai game spin di dalam webview; aplikasi boleh mengabaikannya.
|
||||
|
||||
Game yang dinonaktifkan admin hilang dari daftar ini. Muat ulang daftar setiap kali
|
||||
layar dibuka.
|
||||
|
||||
### 8.2 Membuka game
|
||||
|
||||
1. Customer menekan Main → buka webview layar penuh dengan `game_url`.
|
||||
2. Pasang bridge (§8.3) **sebelum** halaman dimuat.
|
||||
3. Saat game mengirim `ready`, jawab dengan `init`.
|
||||
4. Saat game mengirim `close`, tutup webview, lalu muat ulang beranda wallet (§4.1).
|
||||
|
||||
Jangan menaruh token di URL `game_url` (query string atau fragment): URL bisa tercatat di
|
||||
log server game dan riwayat webview.
|
||||
|
||||
### 8.3 Bridge (sisi aplikasi)
|
||||
|
||||
> **Usulan.** Kontrak ini sama dengan [`integration-enakgame.md`](./integration-enakgame.md)
|
||||
> §2 dan belum diimplementasikan. Sepakati dengan tim EnakGame sebelum mulai.
|
||||
|
||||
- Game → aplikasi: JavaScript channel webview bernama **`EnakGameHost`**; setiap pesan
|
||||
berupa JSON string.
|
||||
- Aplikasi → game: jalankan `window.enakGame.receive('<json>')` di webview.
|
||||
|
||||
| Pesan masuk dari game | Yang dilakukan aplikasi |
|
||||
|---|---|
|
||||
| `{ "type": "ready" }` | Kirim `{ "type": "init", "api_base_url": "<base URL>/api/v1", "token": "<token customer>", "game_id": "<id game yang dibuka>" }` |
|
||||
| `{ "type": "token_expired" }` | Login ulang customer (tidak ada refresh token), lalu kirim `{ "type": "token", "token": "<token baru>" }` |
|
||||
| `{ "type": "balance_changed", "coin_balance": 15 }` | Perbarui saldo EnakCoin yang ditampilkan aplikasi |
|
||||
| `{ "type": "close" }` | Tutup webview, muat ulang beranda |
|
||||
|
||||
Abaikan pesan dengan `type` lain. Tombol back Android jangan langsung menutup webview:
|
||||
tampilkan konfirmasi "Keluar dari game? EnakCoin yang sudah dipakai untuk main tidak
|
||||
kembali", lalu tutup. Tidak perlu mengirim pesan ke game.
|
||||
|
||||
### 8.4 Riwayat main — `GET /customer/enakgame/sessions?page=1&limit=20`
|
||||
|
||||
Query opsional `game_id` (riwayat satu game) dan `status` (`STARTED`, `COMPLETED`,
|
||||
`REFUNDED`, `EXPIRED`) untuk filter atau tab.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "…", "game_id": "8a1f…", "status": "COMPLETED", "entry_cost": 5, "reward_total": 10,
|
||||
"started_at": "…", "expires_at": "…", "ended_at": "…", "refund_reason": null
|
||||
}
|
||||
],
|
||||
"pagination": { "page": 1, "limit": 20, "total_count": 3, "total_pages": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
| `status` | Label | Keterangan |
|
||||
|---|---|---|
|
||||
| `STARTED` | Sedang dimainkan | |
|
||||
| `COMPLETED` | Selesai | "Dapat {reward_total} EnakCoin" |
|
||||
| `REFUNDED` | Dikembalikan | Entry cost kembali; `refund_reason` `SYSTEM_ERROR` atau `GAME_DEACTIVATED` |
|
||||
| `EXPIRED` | Tidak selesai | Hasil tidak dikirim sebelum batas waktu; entry cost tidak kembali |
|
||||
|
||||
Nama game diambil dari daftar game (§8.1) lewat `game_id`. Detail satu session:
|
||||
`GET /customer/enakgame/sessions/:id`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Voucher (tukar EnakPoint)
|
||||
|
||||
### 9.1 Katalog — `GET /customer/vouchers`
|
||||
|
||||
Voucher yang bisa ditukar sekarang: aktif, dalam masa berlaku, dan masih ada stoknya.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "…",
|
||||
"name": "Kopi Susu Gratis",
|
||||
"description": "Berlaku untuk ukuran regular",
|
||||
"image_url": "https://…/kopi.png",
|
||||
"voucher_type": "FREE_ITEM",
|
||||
"face_value": 20000,
|
||||
"point_cost": 15000,
|
||||
"max_per_customer": 2,
|
||||
"valid_until": "2026-12-31T16:59:59Z",
|
||||
"terms": { "…": "syarat & ketentuan, objek JSON bebas" },
|
||||
"available": 120
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- `point_cost`: EnakPoint yang dipotong. Tombol Tukar nonaktif bila `point_balance`
|
||||
kurang.
|
||||
- `face_value`: nilai voucher dalam rupiah, tampilkan sebagai "senilai Rp 20.000".
|
||||
- `available`: sisa stok; `null` berarti stok tidak dihitung. Bila 0, tampilkan "Habis".
|
||||
- `max_per_customer`: batas tukar per customer; `null` = tanpa batas.
|
||||
- `terms`: objek JSON yang isinya diatur admin. Sepakati bentuknya dengan tim
|
||||
backoffice; sebelum itu tampilkan `description` saja.
|
||||
|
||||
| `voucher_type` | Label usulan |
|
||||
|---|---|
|
||||
| `FIXED_VALUE` | Potongan Rp {face_value} |
|
||||
| `PERCENTAGE` | Potongan persen |
|
||||
| `FREE_ITEM` | Gratis item |
|
||||
| `MERCHANT_BENEFIT` | Benefit merchant |
|
||||
|
||||
### 9.2 Tukar — `POST /customer/vouchers/:id/redeem`
|
||||
|
||||
Konfirmasi ("Tukar {point_cost} EnakPoint dengan {name}? Tidak bisa dibatalkan.") →
|
||||
minta PIN → kirim dengan header `Idempotency-Key`:
|
||||
|
||||
```json
|
||||
{ "pin": "482913" }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "…",
|
||||
"voucher_id": "…",
|
||||
"voucher_name": "Kopi Susu Gratis",
|
||||
"voucher_image_url": "https://…/kopi.png",
|
||||
"voucher_type": "FREE_ITEM",
|
||||
"status": "COMPLETED",
|
||||
"face_value": 20000,
|
||||
"point_cost": 15000,
|
||||
"code": "KOPI-7F3C-2291",
|
||||
"code_expires_at": "2026-12-31T16:59:59Z",
|
||||
"completed_at": "…",
|
||||
"created_at": "…",
|
||||
"point_balance": 2500,
|
||||
"replayed": false
|
||||
}
|
||||
```
|
||||
|
||||
- Layar sukses: voucher, `code` bila ada (bisa disalin), masa berlaku, dan saldo
|
||||
EnakPoint baru (`point_balance`).
|
||||
- `code` bisa `null`: voucher ini tidak memakai kode; tunjukkan layar voucher ke kasir.
|
||||
- `status: "PENDING"`: voucher sedang diproses penyedia luar (belum ada voucher seperti
|
||||
ini di katalog, tapi tangani dari sekarang). EnakPoint sudah terpotong;
|
||||
tampilkan "Voucher sedang diproses" dan cek lagi di Voucher saya (§9.3). Bila akhirnya
|
||||
`FAILED`, EnakPoint dikembalikan otomatis (mutasi `REWARD_REDEEM_REFUND`).
|
||||
- Error PIN ditangani sesuai §6.5.
|
||||
|
||||
| Penolakan `304` (`cause`) | Tampilan |
|
||||
|---|---|
|
||||
| `not enough EnakPoint` | "EnakPoint kamu kurang." |
|
||||
| `the voucher is out of stock` | "Voucher sudah habis." Muat ulang katalog |
|
||||
| `this voucher can be redeemed at most … times per customer` | "Kamu sudah mencapai batas penukaran voucher ini." |
|
||||
| `the voucher is not available`, `… cannot be redeemed yet`, `… has ended`, `… not available yet` | "Voucher tidak tersedia." Muat ulang katalog |
|
||||
| `the customer is not active` | "Akun tidak aktif." |
|
||||
| `this Idempotency-Key was already used to redeem another voucher` | Bug di app: key dipakai ulang |
|
||||
|
||||
### 9.3 Voucher saya — `GET /customer/vouchers/redemptions?page=1&limit=20`
|
||||
|
||||
Daftar penukaran customer, terbaru di atas, dengan bentuk item sama seperti response
|
||||
§9.2 (tanpa `point_balance` dan `replayed`). Seperti semua daftar berhalaman di dokumen
|
||||
ini, array-nya ada di `data.data` dan pagination di `data.pagination` di dalam amplop
|
||||
response (§2.2); daftar kosong berupa `[]`.
|
||||
|
||||
| `status` | Tampilan |
|
||||
|---|---|
|
||||
| `COMPLETED` | Voucher siap dipakai: nama, `code` (bila ada), berlaku sampai `code_expires_at` |
|
||||
| `PENDING` | "Sedang diproses" |
|
||||
| `FAILED` | "Gagal, EnakPoint sudah dikembalikan" |
|
||||
|
||||
**Memakai voucher di outlet:** customer menunjukkan layar voucher ke kasir. POS belum
|
||||
bisa menandai voucher terpakai, jadi aplikasi belum bisa menampilkan status "sudah
|
||||
dipakai" ([`integration-pos.md`](./integration-pos.md) §5).
|
||||
|
||||
---
|
||||
|
||||
## 10. Yang sudah dihapus / deprecated
|
||||
|
||||
Sudah **dihapus** dari API (jangan dipanggil, akan error / tidak ada):
|
||||
|
||||
| Lama | Pengganti |
|
||||
|---|---|
|
||||
| `POST /customer/spin` | Game EnakGame di webview (§8) |
|
||||
| `GET /customer/games`, `GET /customer/ferris-wheel` | `GET /customer/enakgame/games` |
|
||||
| `coins_used`, `coins_remaining`, `prize_won`, `game_play` di response spin | Tidak ada; hasil game ditampilkan di dalam game |
|
||||
| `metadata.coin_cost` pada data game | `entry_cost` |
|
||||
| `GET /customer/tokens` | `GET /customer/wallet` → `coin_balance` |
|
||||
| `total_tokens`, `tokens_history`, `token_used`, `tokens_remaining` | `coin_balance`, `GET /customer/wallet/transactions?currency=COIN` |
|
||||
| `POST /customer/wallet/payment-code` | Tidak ada; EnakPoint tidak bisa untuk bayar |
|
||||
| `POST /customer/orders/:id/pay-with-points` | Tidak ada; EnakPoint tidak bisa untuk bayar |
|
||||
| `accepts_point_payment` di `GET /customer/outlets` | – |
|
||||
| `points_used`, `point_value` di `payments` pada `GET /customer/orders/:id` | – |
|
||||
| Tipe mutasi `PAYMENT`, `PAYMENT_REFUND` di riwayat | Tidak ditulis lagi |
|
||||
|
||||
Masih ada tapi **deprecated** (akan dihapus, jangan dipakai di kode baru):
|
||||
|
||||
| Lama | Pengganti |
|
||||
|---|---|
|
||||
| `GET /customer/points` | `GET /customer/wallet` → `point_balance` |
|
||||
| `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` |
|
||||
|
||||
---
|
||||
|
||||
## 11. Checklist selesai
|
||||
|
||||
- [ ] Beranda menampilkan saldo EnakPoint ("setara potongan Rp …"), EnakCoin, dan banner kedaluwarsa terdekat.
|
||||
- [ ] Riwayat dengan tab per mata uang, filter tipe/tanggal, infinite scroll, dan label semua tipe di §4.2, termasuk tipe game dan voucher.
|
||||
- [ ] Layar saldo akan kedaluwarsa.
|
||||
- [ ] Registrasi device FCM setelah login dan saat token berganti; unregister saat logout.
|
||||
- [ ] Penanganan tap untuk keempat tipe push.
|
||||
- [ ] PIN diminta hanya saat aksi yang membutuhkan; alur buat, ganti, dan lupa PIN lewat OTP.
|
||||
- [ ] Keempat error PIN ditangani di semua layar yang meminta PIN (tukar, transfer, voucher).
|
||||
- [ ] Tukar dengan preview, kelipatan kurs, konfirmasi, `Idempotency-Key`, retry dengan key sama.
|
||||
- [ ] Transfer dengan cek penerima tersamar, konfirmasi, `Idempotency-Key`, retry dengan key sama.
|
||||
- [ ] Daftar game dengan biaya, badge event, dan tombol nonaktif bila EnakCoin kurang.
|
||||
- [ ] Webview game dengan bridge §8.3; token tidak pernah di URL; beranda dimuat ulang saat game ditutup.
|
||||
- [ ] Riwayat main dengan label status.
|
||||
- [ ] Katalog voucher, tukar dengan PIN dan `Idempotency-Key`, status `PENDING` ditangani.
|
||||
- [ ] Voucher saya dengan kode yang bisa disalin.
|
||||
- [ ] Riwayat order dengan pagination dan layar detail (item, pembayaran, EnakPoint/EnakCoin yang didapat).
|
||||
- [ ] Tidak ada pemakaian endpoint atau field di §10.
|
||||
- [ ] PIN dan token tidak pernah disimpan sembarangan, di-log, atau dikirim ke analytics.
|
||||
@@ -18,7 +18,7 @@
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>com.apskel.enaklocustomer</key>
|
||||
<string>Enaklo App Store</string>
|
||||
<string>Enaklo Customer</string>
|
||||
</dict>
|
||||
|
||||
<key>stripSwiftSymbols</key>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import '../../../domain/game/game.dart';
|
||||
|
||||
part 'ferris_wheel_loader_event.dart';
|
||||
part 'ferris_wheel_loader_state.dart';
|
||||
part 'ferris_wheel_loader_bloc.freezed.dart';
|
||||
|
||||
@injectable
|
||||
class FerrisWheelLoaderBloc
|
||||
extends Bloc<FerrisWheelLoaderEvent, FerrisWheelLoaderState> {
|
||||
final IGameRepository _repository;
|
||||
FerrisWheelLoaderBloc(this._repository)
|
||||
: super(FerrisWheelLoaderState.initial()) {
|
||||
on<FerrisWheelLoaderEvent>(_onFerrisWheelLoaderEvent);
|
||||
}
|
||||
|
||||
Future<void> _onFerrisWheelLoaderEvent(
|
||||
FerrisWheelLoaderEvent event,
|
||||
Emitter<FerrisWheelLoaderState> emit,
|
||||
) {
|
||||
return event.map(
|
||||
fetched: (e) async {
|
||||
emit(
|
||||
state.copyWith(isFetching: true, failureOptionFerrisWheel: none()),
|
||||
);
|
||||
|
||||
final result = await _repository.ferrisWheel();
|
||||
|
||||
var data = result.fold(
|
||||
(f) => state.copyWith(failureOptionFerrisWheel: optionOf(f)),
|
||||
(ferrisWheel) => state.copyWith(ferrisWheel: ferrisWheel),
|
||||
);
|
||||
|
||||
emit(data.copyWith(isFetching: false));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
part of 'ferris_wheel_loader_bloc.dart';
|
||||
|
||||
@freezed
|
||||
abstract class FerrisWheelLoaderEvent with _$FerrisWheelLoaderEvent {
|
||||
const factory FerrisWheelLoaderEvent.fetched() = _Fetched;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
part of 'ferris_wheel_loader_bloc.dart';
|
||||
|
||||
@freezed
|
||||
abstract class FerrisWheelLoaderState with _$FerrisWheelLoaderState {
|
||||
const factory FerrisWheelLoaderState({
|
||||
required Game ferrisWheel,
|
||||
required Option<GameFailure> failureOptionFerrisWheel,
|
||||
@Default(false) bool isFetching,
|
||||
}) = _FerrisWheelLoaderState;
|
||||
|
||||
factory FerrisWheelLoaderState.initial() => FerrisWheelLoaderState(
|
||||
ferrisWheel: Game.empty(),
|
||||
failureOptionFerrisWheel: none(),
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import '../../../domain/game/game.dart';
|
||||
|
||||
part 'game_spin_event.dart';
|
||||
part 'game_spin_state.dart';
|
||||
part 'game_spin_bloc.freezed.dart';
|
||||
|
||||
@injectable
|
||||
class GameSpinBloc extends Bloc<GameSpinEvent, GameSpinState> {
|
||||
final IGameRepository _repository;
|
||||
|
||||
GameSpinBloc(this._repository) : super(GameSpinState.initial()) {
|
||||
on<GameSpinEvent>(_onGameSpinEvent);
|
||||
}
|
||||
|
||||
Future<void> _onGameSpinEvent(
|
||||
GameSpinEvent event,
|
||||
Emitter<GameSpinState> emit,
|
||||
) {
|
||||
return event.map(
|
||||
spun: (e) async {
|
||||
if (state.isSpinning) return;
|
||||
emit(state.copyWith(isSpinning: true, failureOrResultOption: none()));
|
||||
|
||||
final result = await _repository.spin(gameId: e.gameId);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSpinning: false,
|
||||
failureOrResultOption: optionOf(result),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,546 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'game_spin_bloc.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$GameSpinEvent {
|
||||
|
||||
String get gameId;
|
||||
/// Create a copy of GameSpinEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$GameSpinEventCopyWith<GameSpinEvent> get copyWith => _$GameSpinEventCopyWithImpl<GameSpinEvent>(this as GameSpinEvent, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
final _this = this as GameSpinEvent;
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is GameSpinEvent&&(identical(other.gameId, _this.gameId) || other.gameId == _this.gameId));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
final _this = this as GameSpinEvent;
|
||||
return Object.hash(runtimeType,_this.gameId);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final _this = this as GameSpinEvent;
|
||||
return 'GameSpinEvent(gameId: ${_this.gameId})';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $GameSpinEventCopyWith<$Res> {
|
||||
factory $GameSpinEventCopyWith(GameSpinEvent value, $Res Function(GameSpinEvent) _then) = _$GameSpinEventCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String gameId
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$GameSpinEventCopyWithImpl<$Res>
|
||||
implements $GameSpinEventCopyWith<$Res> {
|
||||
_$GameSpinEventCopyWithImpl(this._self, this._then);
|
||||
|
||||
final GameSpinEvent _self;
|
||||
final $Res Function(GameSpinEvent) _then;
|
||||
|
||||
/// Create a copy of GameSpinEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? gameId = null,}) {
|
||||
return _then(GameSpinEvent.spun(
|
||||
null == gameId ? _self.gameId : gameId // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [GameSpinEvent].
|
||||
extension GameSpinEventPatterns on GameSpinEvent {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( _Spun value)? spun,required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Spun() when spun != null:
|
||||
return spun(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( _Spun value) spun,}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Spun():
|
||||
return spun(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( _Spun value)? spun,}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Spun() when spun != null:
|
||||
return spun(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String gameId)? spun,required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Spun() when spun != null:
|
||||
return spun(_that.gameId);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String gameId) spun,}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Spun():
|
||||
return spun(_that.gameId);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String gameId)? spun,}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Spun() when spun != null:
|
||||
return spun(_that.gameId);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _Spun implements GameSpinEvent {
|
||||
const _Spun(this.gameId);
|
||||
|
||||
|
||||
@override final String gameId;
|
||||
|
||||
/// Create a copy of GameSpinEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$SpunCopyWith<_Spun> get copyWith => __$SpunCopyWithImpl<_Spun>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Spun&&(identical(other.gameId, gameId) || other.gameId == gameId));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(runtimeType,gameId);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'GameSpinEvent.spun(gameId: $gameId)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$SpunCopyWith<$Res> implements $GameSpinEventCopyWith<$Res> {
|
||||
factory _$SpunCopyWith(_Spun value, $Res Function(_Spun) _then) = __$SpunCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String gameId
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$SpunCopyWithImpl<$Res>
|
||||
implements _$SpunCopyWith<$Res> {
|
||||
__$SpunCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _Spun _self;
|
||||
final $Res Function(_Spun) _then;
|
||||
|
||||
/// Create a copy of GameSpinEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? gameId = null,}) {
|
||||
return _then(_Spun(
|
||||
null == gameId ? _self.gameId : gameId // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$GameSpinState {
|
||||
|
||||
bool get isSpinning; Option<Either<GameFailure, SpinResult>> get failureOrResultOption;
|
||||
/// Create a copy of GameSpinState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$GameSpinStateCopyWith<GameSpinState> get copyWith => _$GameSpinStateCopyWithImpl<GameSpinState>(this as GameSpinState, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
final _this = this as GameSpinState;
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is GameSpinState&&(identical(other.isSpinning, _this.isSpinning) || other.isSpinning == _this.isSpinning)&&(identical(other.failureOrResultOption, _this.failureOrResultOption) || other.failureOrResultOption == _this.failureOrResultOption));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
final _this = this as GameSpinState;
|
||||
return Object.hash(runtimeType,_this.isSpinning,_this.failureOrResultOption);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final _this = this as GameSpinState;
|
||||
return 'GameSpinState(isSpinning: ${_this.isSpinning}, failureOrResultOption: ${_this.failureOrResultOption})';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $GameSpinStateCopyWith<$Res> {
|
||||
factory $GameSpinStateCopyWith(GameSpinState value, $Res Function(GameSpinState) _then) = _$GameSpinStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
bool isSpinning, Option<Either<GameFailure, SpinResult>> failureOrResultOption
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$GameSpinStateCopyWithImpl<$Res>
|
||||
implements $GameSpinStateCopyWith<$Res> {
|
||||
_$GameSpinStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final GameSpinState _self;
|
||||
final $Res Function(GameSpinState) _then;
|
||||
|
||||
/// Create a copy of GameSpinState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? isSpinning = null,Object? failureOrResultOption = null,}) {
|
||||
return _then(GameSpinState(
|
||||
isSpinning: null == isSpinning ? _self.isSpinning : isSpinning // ignore: cast_nullable_to_non_nullable
|
||||
as bool,failureOrResultOption: null == failureOrResultOption ? _self.failureOrResultOption : failureOrResultOption // ignore: cast_nullable_to_non_nullable
|
||||
as Option<Either<GameFailure, SpinResult>>,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [GameSpinState].
|
||||
extension GameSpinStatePatterns on GameSpinState {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _GameSpinState value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _GameSpinState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _GameSpinState value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _GameSpinState():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _GameSpinState value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _GameSpinState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isSpinning, Option<Either<GameFailure, SpinResult>> failureOrResultOption)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _GameSpinState() when $default != null:
|
||||
return $default(_that.isSpinning,_that.failureOrResultOption);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isSpinning, Option<Either<GameFailure, SpinResult>> failureOrResultOption) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _GameSpinState():
|
||||
return $default(_that.isSpinning,_that.failureOrResultOption);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isSpinning, Option<Either<GameFailure, SpinResult>> failureOrResultOption)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _GameSpinState() when $default != null:
|
||||
return $default(_that.isSpinning,_that.failureOrResultOption);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _GameSpinState implements GameSpinState {
|
||||
const _GameSpinState({this.isSpinning = false, required this.failureOrResultOption});
|
||||
|
||||
|
||||
@override@JsonKey() final bool isSpinning;
|
||||
@override final Option<Either<GameFailure, SpinResult>> failureOrResultOption;
|
||||
|
||||
/// Create a copy of GameSpinState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$GameSpinStateCopyWith<_GameSpinState> get copyWith => __$GameSpinStateCopyWithImpl<_GameSpinState>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _GameSpinState&&(identical(other.isSpinning, isSpinning) || other.isSpinning == isSpinning)&&(identical(other.failureOrResultOption, failureOrResultOption) || other.failureOrResultOption == failureOrResultOption));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(runtimeType,isSpinning,failureOrResultOption);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'GameSpinState(isSpinning: $isSpinning, failureOrResultOption: $failureOrResultOption)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$GameSpinStateCopyWith<$Res> implements $GameSpinStateCopyWith<$Res> {
|
||||
factory _$GameSpinStateCopyWith(_GameSpinState value, $Res Function(_GameSpinState) _then) = __$GameSpinStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
bool isSpinning, Option<Either<GameFailure, SpinResult>> failureOrResultOption
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$GameSpinStateCopyWithImpl<$Res>
|
||||
implements _$GameSpinStateCopyWith<$Res> {
|
||||
__$GameSpinStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _GameSpinState _self;
|
||||
final $Res Function(_GameSpinState) _then;
|
||||
|
||||
/// Create a copy of GameSpinState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? isSpinning = null,Object? failureOrResultOption = null,}) {
|
||||
return _then(_GameSpinState(
|
||||
isSpinning: null == isSpinning ? _self.isSpinning : isSpinning // ignore: cast_nullable_to_non_nullable
|
||||
as bool,failureOrResultOption: null == failureOrResultOption ? _self.failureOrResultOption : failureOrResultOption // ignore: cast_nullable_to_non_nullable
|
||||
as Option<Either<GameFailure, SpinResult>>,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -1,6 +0,0 @@
|
||||
part of 'game_spin_bloc.dart';
|
||||
|
||||
@freezed
|
||||
abstract class GameSpinEvent with _$GameSpinEvent {
|
||||
const factory GameSpinEvent.spun(String gameId) = _Spun;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
part of 'game_spin_bloc.dart';
|
||||
|
||||
@freezed
|
||||
abstract class GameSpinState with _$GameSpinState {
|
||||
const factory GameSpinState({
|
||||
@Default(false) bool isSpinning,
|
||||
required Option<Either<GameFailure, SpinResult>> failureOrResultOption,
|
||||
}) = _GameSpinState;
|
||||
|
||||
factory GameSpinState.initial() =>
|
||||
GameSpinState(failureOrResultOption: none());
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import '../../../domain/wallet/wallet.dart';
|
||||
|
||||
part 'voucher_loader_event.dart';
|
||||
part 'voucher_loader_state.dart';
|
||||
part 'voucher_loader_bloc.freezed.dart';
|
||||
|
||||
/// Katalog voucher yang bisa ditukar (§9.1).
|
||||
@injectable
|
||||
class VoucherLoaderBloc extends Bloc<VoucherLoaderEvent, VoucherLoaderState> {
|
||||
final IVoucherRepository _repository;
|
||||
|
||||
VoucherLoaderBloc(this._repository) : super(VoucherLoaderState.initial()) {
|
||||
on<VoucherLoaderEvent>(_onVoucherLoaderEvent);
|
||||
}
|
||||
|
||||
Future<void> _onVoucherLoaderEvent(
|
||||
VoucherLoaderEvent event,
|
||||
Emitter<VoucherLoaderState> emit,
|
||||
) {
|
||||
return event.map(
|
||||
fetched: (e) async {
|
||||
emit(state.copyWith(isFetching: true, failureOption: none()));
|
||||
|
||||
final result = await _repository.getVouchers();
|
||||
|
||||
final data = result.fold(
|
||||
(f) => state.copyWith(failureOption: optionOf(f)),
|
||||
(vouchers) => state.copyWith(vouchers: vouchers, hasLoaded: true),
|
||||
);
|
||||
|
||||
emit(data.copyWith(isFetching: false));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+88
-97
@@ -3,7 +3,7 @@
|
||||
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'ferris_wheel_loader_bloc.dart';
|
||||
part of 'voucher_loader_bloc.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
@@ -13,7 +13,7 @@ part of 'ferris_wheel_loader_bloc.dart';
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$FerrisWheelLoaderEvent {
|
||||
mixin _$VoucherLoaderEvent {
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ mixin _$FerrisWheelLoaderEvent {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is FerrisWheelLoaderEvent);
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is VoucherLoaderEvent);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,20 +30,20 @@ int get hashCode => runtimeType.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'FerrisWheelLoaderEvent()';
|
||||
return 'VoucherLoaderEvent()';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class $FerrisWheelLoaderEventCopyWith<$Res> {
|
||||
$FerrisWheelLoaderEventCopyWith(FerrisWheelLoaderEvent _, $Res Function(FerrisWheelLoaderEvent) __);
|
||||
class $VoucherLoaderEventCopyWith<$Res> {
|
||||
$VoucherLoaderEventCopyWith(VoucherLoaderEvent _, $Res Function(VoucherLoaderEvent) __);
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [FerrisWheelLoaderEvent].
|
||||
extension FerrisWheelLoaderEventPatterns on FerrisWheelLoaderEvent {
|
||||
/// Adds pattern-matching-related methods to [VoucherLoaderEvent].
|
||||
extension VoucherLoaderEventPatterns on VoucherLoaderEvent {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
@@ -175,7 +175,7 @@ return fetched();case _:
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _Fetched implements FerrisWheelLoaderEvent {
|
||||
class _Fetched implements VoucherLoaderEvent {
|
||||
const _Fetched();
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ int get hashCode => runtimeType.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'FerrisWheelLoaderEvent.fetched()';
|
||||
return 'VoucherLoaderEvent.fetched()';
|
||||
}
|
||||
|
||||
|
||||
@@ -205,84 +205,76 @@ String toString() {
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$FerrisWheelLoaderState {
|
||||
mixin _$VoucherLoaderState {
|
||||
|
||||
Game get ferrisWheel; Option<GameFailure> get failureOptionFerrisWheel; bool get isFetching;
|
||||
/// Create a copy of FerrisWheelLoaderState
|
||||
List<Voucher> get vouchers; Option<WalletFailure> get failureOption; bool get isFetching; bool get hasLoaded;
|
||||
/// Create a copy of VoucherLoaderState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$FerrisWheelLoaderStateCopyWith<FerrisWheelLoaderState> get copyWith => _$FerrisWheelLoaderStateCopyWithImpl<FerrisWheelLoaderState>(this as FerrisWheelLoaderState, _$identity);
|
||||
$VoucherLoaderStateCopyWith<VoucherLoaderState> get copyWith => _$VoucherLoaderStateCopyWithImpl<VoucherLoaderState>(this as VoucherLoaderState, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
final _this = this as FerrisWheelLoaderState;
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is FerrisWheelLoaderState&&(identical(other.ferrisWheel, _this.ferrisWheel) || other.ferrisWheel == _this.ferrisWheel)&&(identical(other.failureOptionFerrisWheel, _this.failureOptionFerrisWheel) || other.failureOptionFerrisWheel == _this.failureOptionFerrisWheel)&&(identical(other.isFetching, _this.isFetching) || other.isFetching == _this.isFetching));
|
||||
final _this = this as VoucherLoaderState;
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is VoucherLoaderState&&const DeepCollectionEquality().equals(other.vouchers, _this.vouchers)&&(identical(other.failureOption, _this.failureOption) || other.failureOption == _this.failureOption)&&(identical(other.isFetching, _this.isFetching) || other.isFetching == _this.isFetching)&&(identical(other.hasLoaded, _this.hasLoaded) || other.hasLoaded == _this.hasLoaded));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
final _this = this as FerrisWheelLoaderState;
|
||||
return Object.hash(runtimeType,_this.ferrisWheel,_this.failureOptionFerrisWheel,_this.isFetching);
|
||||
final _this = this as VoucherLoaderState;
|
||||
return Object.hash(runtimeType,const DeepCollectionEquality().hash(_this.vouchers),_this.failureOption,_this.isFetching,_this.hasLoaded);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final _this = this as FerrisWheelLoaderState;
|
||||
return 'FerrisWheelLoaderState(ferrisWheel: ${_this.ferrisWheel}, failureOptionFerrisWheel: ${_this.failureOptionFerrisWheel}, isFetching: ${_this.isFetching})';
|
||||
final _this = this as VoucherLoaderState;
|
||||
return 'VoucherLoaderState(vouchers: ${_this.vouchers}, failureOption: ${_this.failureOption}, isFetching: ${_this.isFetching}, hasLoaded: ${_this.hasLoaded})';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $FerrisWheelLoaderStateCopyWith<$Res> {
|
||||
factory $FerrisWheelLoaderStateCopyWith(FerrisWheelLoaderState value, $Res Function(FerrisWheelLoaderState) _then) = _$FerrisWheelLoaderStateCopyWithImpl;
|
||||
abstract mixin class $VoucherLoaderStateCopyWith<$Res> {
|
||||
factory $VoucherLoaderStateCopyWith(VoucherLoaderState value, $Res Function(VoucherLoaderState) _then) = _$VoucherLoaderStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
Game ferrisWheel, Option<GameFailure> failureOptionFerrisWheel, bool isFetching
|
||||
List<Voucher> vouchers, Option<WalletFailure> failureOption, bool isFetching, bool hasLoaded
|
||||
});
|
||||
|
||||
|
||||
$GameCopyWith<$Res> get ferrisWheel;
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$FerrisWheelLoaderStateCopyWithImpl<$Res>
|
||||
implements $FerrisWheelLoaderStateCopyWith<$Res> {
|
||||
_$FerrisWheelLoaderStateCopyWithImpl(this._self, this._then);
|
||||
class _$VoucherLoaderStateCopyWithImpl<$Res>
|
||||
implements $VoucherLoaderStateCopyWith<$Res> {
|
||||
_$VoucherLoaderStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final FerrisWheelLoaderState _self;
|
||||
final $Res Function(FerrisWheelLoaderState) _then;
|
||||
final VoucherLoaderState _self;
|
||||
final $Res Function(VoucherLoaderState) _then;
|
||||
|
||||
/// Create a copy of FerrisWheelLoaderState
|
||||
/// Create a copy of VoucherLoaderState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? ferrisWheel = null,Object? failureOptionFerrisWheel = null,Object? isFetching = null,}) {
|
||||
return _then(FerrisWheelLoaderState(
|
||||
ferrisWheel: null == ferrisWheel ? _self.ferrisWheel : ferrisWheel // ignore: cast_nullable_to_non_nullable
|
||||
as Game,failureOptionFerrisWheel: null == failureOptionFerrisWheel ? _self.failureOptionFerrisWheel : failureOptionFerrisWheel // ignore: cast_nullable_to_non_nullable
|
||||
as Option<GameFailure>,isFetching: null == isFetching ? _self.isFetching : isFetching // ignore: cast_nullable_to_non_nullable
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? vouchers = null,Object? failureOption = null,Object? isFetching = null,Object? hasLoaded = null,}) {
|
||||
return _then(VoucherLoaderState(
|
||||
vouchers: null == vouchers ? _self.vouchers : vouchers // ignore: cast_nullable_to_non_nullable
|
||||
as List<Voucher>,failureOption: null == failureOption ? _self.failureOption : failureOption // ignore: cast_nullable_to_non_nullable
|
||||
as Option<WalletFailure>,isFetching: null == isFetching ? _self.isFetching : isFetching // ignore: cast_nullable_to_non_nullable
|
||||
as bool,hasLoaded: null == hasLoaded ? _self.hasLoaded : hasLoaded // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
/// Create a copy of FerrisWheelLoaderState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$GameCopyWith<$Res> get ferrisWheel {
|
||||
|
||||
return $GameCopyWith<$Res>(_self.ferrisWheel, (value) {
|
||||
return _then(_self.copyWith(ferrisWheel: value));
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [FerrisWheelLoaderState].
|
||||
extension FerrisWheelLoaderStatePatterns on FerrisWheelLoaderState {
|
||||
/// Adds pattern-matching-related methods to [VoucherLoaderState].
|
||||
extension VoucherLoaderStatePatterns on VoucherLoaderState {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
@@ -295,10 +287,10 @@ extension FerrisWheelLoaderStatePatterns on FerrisWheelLoaderState {
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _FerrisWheelLoaderState value)? $default,{required TResult orElse(),}){
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _VoucherLoaderState value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _FerrisWheelLoaderState() when $default != null:
|
||||
case _VoucherLoaderState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
@@ -317,10 +309,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _FerrisWheelLoaderState value) $default,){
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _VoucherLoaderState value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _FerrisWheelLoaderState():
|
||||
case _VoucherLoaderState():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
@@ -338,10 +330,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _FerrisWheelLoaderState value)? $default,){
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _VoucherLoaderState value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _FerrisWheelLoaderState() when $default != null:
|
||||
case _VoucherLoaderState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
@@ -359,10 +351,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( Game ferrisWheel, Option<GameFailure> failureOptionFerrisWheel, bool isFetching)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<Voucher> vouchers, Option<WalletFailure> failureOption, bool isFetching, bool hasLoaded)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _FerrisWheelLoaderState() when $default != null:
|
||||
return $default(_that.ferrisWheel,_that.failureOptionFerrisWheel,_that.isFetching);case _:
|
||||
case _VoucherLoaderState() when $default != null:
|
||||
return $default(_that.vouchers,_that.failureOption,_that.isFetching,_that.hasLoaded);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -380,10 +372,10 @@ return $default(_that.ferrisWheel,_that.failureOptionFerrisWheel,_that.isFetchin
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( Game ferrisWheel, Option<GameFailure> failureOptionFerrisWheel, bool isFetching) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<Voucher> vouchers, Option<WalletFailure> failureOption, bool isFetching, bool hasLoaded) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _FerrisWheelLoaderState():
|
||||
return $default(_that.ferrisWheel,_that.failureOptionFerrisWheel,_that.isFetching);case _:
|
||||
case _VoucherLoaderState():
|
||||
return $default(_that.vouchers,_that.failureOption,_that.isFetching,_that.hasLoaded);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
@@ -400,10 +392,10 @@ return $default(_that.ferrisWheel,_that.failureOptionFerrisWheel,_that.isFetchin
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( Game ferrisWheel, Option<GameFailure> failureOptionFerrisWheel, bool isFetching)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<Voucher> vouchers, Option<WalletFailure> failureOption, bool isFetching, bool hasLoaded)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _FerrisWheelLoaderState() when $default != null:
|
||||
return $default(_that.ferrisWheel,_that.failureOptionFerrisWheel,_that.isFetching);case _:
|
||||
case _VoucherLoaderState() when $default != null:
|
||||
return $default(_that.vouchers,_that.failureOption,_that.isFetching,_that.hasLoaded);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -414,82 +406,81 @@ return $default(_that.ferrisWheel,_that.failureOptionFerrisWheel,_that.isFetchin
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _FerrisWheelLoaderState implements FerrisWheelLoaderState {
|
||||
const _FerrisWheelLoaderState({required this.ferrisWheel, required this.failureOptionFerrisWheel, this.isFetching = false});
|
||||
class _VoucherLoaderState implements VoucherLoaderState {
|
||||
const _VoucherLoaderState({required List<Voucher> vouchers, required this.failureOption, this.isFetching = false, this.hasLoaded = false}): _vouchers = vouchers;
|
||||
|
||||
|
||||
@override final Game ferrisWheel;
|
||||
@override final Option<GameFailure> failureOptionFerrisWheel;
|
||||
@override@JsonKey() final bool isFetching;
|
||||
final List<Voucher> _vouchers;
|
||||
@override List<Voucher> get vouchers {
|
||||
if (_vouchers is EqualUnmodifiableListView) return _vouchers;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_vouchers);
|
||||
}
|
||||
|
||||
/// Create a copy of FerrisWheelLoaderState
|
||||
@override final Option<WalletFailure> failureOption;
|
||||
@override@JsonKey() final bool isFetching;
|
||||
@override@JsonKey() final bool hasLoaded;
|
||||
|
||||
/// Create a copy of VoucherLoaderState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$FerrisWheelLoaderStateCopyWith<_FerrisWheelLoaderState> get copyWith => __$FerrisWheelLoaderStateCopyWithImpl<_FerrisWheelLoaderState>(this, _$identity);
|
||||
_$VoucherLoaderStateCopyWith<_VoucherLoaderState> get copyWith => __$VoucherLoaderStateCopyWithImpl<_VoucherLoaderState>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _FerrisWheelLoaderState&&(identical(other.ferrisWheel, ferrisWheel) || other.ferrisWheel == ferrisWheel)&&(identical(other.failureOptionFerrisWheel, failureOptionFerrisWheel) || other.failureOptionFerrisWheel == failureOptionFerrisWheel)&&(identical(other.isFetching, isFetching) || other.isFetching == isFetching));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _VoucherLoaderState&&const DeepCollectionEquality().equals(other.vouchers, _vouchers)&&(identical(other.failureOption, failureOption) || other.failureOption == failureOption)&&(identical(other.isFetching, isFetching) || other.isFetching == isFetching)&&(identical(other.hasLoaded, hasLoaded) || other.hasLoaded == hasLoaded));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(runtimeType,ferrisWheel,failureOptionFerrisWheel,isFetching);
|
||||
return Object.hash(runtimeType,const DeepCollectionEquality().hash(_vouchers),failureOption,isFetching,hasLoaded);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'FerrisWheelLoaderState(ferrisWheel: $ferrisWheel, failureOptionFerrisWheel: $failureOptionFerrisWheel, isFetching: $isFetching)';
|
||||
return 'VoucherLoaderState(vouchers: $vouchers, failureOption: $failureOption, isFetching: $isFetching, hasLoaded: $hasLoaded)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$FerrisWheelLoaderStateCopyWith<$Res> implements $FerrisWheelLoaderStateCopyWith<$Res> {
|
||||
factory _$FerrisWheelLoaderStateCopyWith(_FerrisWheelLoaderState value, $Res Function(_FerrisWheelLoaderState) _then) = __$FerrisWheelLoaderStateCopyWithImpl;
|
||||
abstract mixin class _$VoucherLoaderStateCopyWith<$Res> implements $VoucherLoaderStateCopyWith<$Res> {
|
||||
factory _$VoucherLoaderStateCopyWith(_VoucherLoaderState value, $Res Function(_VoucherLoaderState) _then) = __$VoucherLoaderStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
Game ferrisWheel, Option<GameFailure> failureOptionFerrisWheel, bool isFetching
|
||||
List<Voucher> vouchers, Option<WalletFailure> failureOption, bool isFetching, bool hasLoaded
|
||||
});
|
||||
|
||||
|
||||
@override $GameCopyWith<$Res> get ferrisWheel;
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$FerrisWheelLoaderStateCopyWithImpl<$Res>
|
||||
implements _$FerrisWheelLoaderStateCopyWith<$Res> {
|
||||
__$FerrisWheelLoaderStateCopyWithImpl(this._self, this._then);
|
||||
class __$VoucherLoaderStateCopyWithImpl<$Res>
|
||||
implements _$VoucherLoaderStateCopyWith<$Res> {
|
||||
__$VoucherLoaderStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _FerrisWheelLoaderState _self;
|
||||
final $Res Function(_FerrisWheelLoaderState) _then;
|
||||
final _VoucherLoaderState _self;
|
||||
final $Res Function(_VoucherLoaderState) _then;
|
||||
|
||||
/// Create a copy of FerrisWheelLoaderState
|
||||
/// Create a copy of VoucherLoaderState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? ferrisWheel = null,Object? failureOptionFerrisWheel = null,Object? isFetching = null,}) {
|
||||
return _then(_FerrisWheelLoaderState(
|
||||
ferrisWheel: null == ferrisWheel ? _self.ferrisWheel : ferrisWheel // ignore: cast_nullable_to_non_nullable
|
||||
as Game,failureOptionFerrisWheel: null == failureOptionFerrisWheel ? _self.failureOptionFerrisWheel : failureOptionFerrisWheel // ignore: cast_nullable_to_non_nullable
|
||||
as Option<GameFailure>,isFetching: null == isFetching ? _self.isFetching : isFetching // ignore: cast_nullable_to_non_nullable
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? vouchers = null,Object? failureOption = null,Object? isFetching = null,Object? hasLoaded = null,}) {
|
||||
return _then(_VoucherLoaderState(
|
||||
vouchers: null == vouchers ? _self._vouchers : vouchers // ignore: cast_nullable_to_non_nullable
|
||||
as List<Voucher>,failureOption: null == failureOption ? _self.failureOption : failureOption // ignore: cast_nullable_to_non_nullable
|
||||
as Option<WalletFailure>,isFetching: null == isFetching ? _self.isFetching : isFetching // ignore: cast_nullable_to_non_nullable
|
||||
as bool,hasLoaded: null == hasLoaded ? _self.hasLoaded : hasLoaded // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
/// Create a copy of FerrisWheelLoaderState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$GameCopyWith<$Res> get ferrisWheel {
|
||||
|
||||
return $GameCopyWith<$Res>(_self.ferrisWheel, (value) {
|
||||
return _then(_self.copyWith(ferrisWheel: value));
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -0,0 +1,6 @@
|
||||
part of 'voucher_loader_bloc.dart';
|
||||
|
||||
@freezed
|
||||
abstract class VoucherLoaderEvent with _$VoucherLoaderEvent {
|
||||
const factory VoucherLoaderEvent.fetched() = _Fetched;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
part of 'voucher_loader_bloc.dart';
|
||||
|
||||
@freezed
|
||||
abstract class VoucherLoaderState with _$VoucherLoaderState {
|
||||
const factory VoucherLoaderState({
|
||||
required List<Voucher> vouchers,
|
||||
required Option<WalletFailure> failureOption,
|
||||
@Default(false) bool isFetching,
|
||||
@Default(false) bool hasLoaded,
|
||||
}) = _VoucherLoaderState;
|
||||
|
||||
factory VoucherLoaderState.initial() =>
|
||||
VoucherLoaderState(vouchers: const [], failureOption: none());
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import '../../../common/function/app_function.dart';
|
||||
import '../../../domain/wallet/wallet.dart';
|
||||
|
||||
part 'voucher_redeem_form_event.dart';
|
||||
part 'voucher_redeem_form_state.dart';
|
||||
part 'voucher_redeem_form_bloc.freezed.dart';
|
||||
|
||||
/// Tukar EnakPoint ke satu voucher (§9.2). Satu bloc per voucher, supaya
|
||||
/// `Idempotency-Key` tidak terbawa ke voucher lain.
|
||||
@injectable
|
||||
class VoucherRedeemFormBloc
|
||||
extends Bloc<VoucherRedeemFormEvent, VoucherRedeemFormState> {
|
||||
final IVoucherRepository _repository;
|
||||
|
||||
VoucherRedeemFormBloc(this._repository)
|
||||
: super(VoucherRedeemFormState.initial()) {
|
||||
on<VoucherRedeemFormEvent>(_onVoucherRedeemFormEvent);
|
||||
}
|
||||
|
||||
Future<void> _onVoucherRedeemFormEvent(
|
||||
VoucherRedeemFormEvent event,
|
||||
Emitter<VoucherRedeemFormState> emit,
|
||||
) {
|
||||
return event.map(
|
||||
submitted: (e) async {
|
||||
// Key baru untuk setiap konfirmasi, kecuali mengulang request yang
|
||||
// sebelumnya gagal karena jaringan.
|
||||
final key = state.idempotencyKey ?? generateUuidV4();
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSubmitting: true,
|
||||
idempotencyKey: key,
|
||||
failureOption: none(),
|
||||
),
|
||||
);
|
||||
|
||||
final result = await _repository.redeem(
|
||||
voucherId: e.voucherId,
|
||||
pin: e.pin,
|
||||
idempotencyKey: key,
|
||||
);
|
||||
|
||||
emit(
|
||||
result
|
||||
.fold(
|
||||
(f) => state.copyWith(
|
||||
failureOption: optionOf(f),
|
||||
idempotencyKey: f.isNetworkError ? key : null,
|
||||
),
|
||||
(redeemResult) =>
|
||||
state.copyWith(result: redeemResult, idempotencyKey: null),
|
||||
)
|
||||
.copyWith(isSubmitting: false),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'voucher_redeem_form_bloc.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$VoucherRedeemFormEvent {
|
||||
|
||||
String get voucherId; String get pin;
|
||||
/// Create a copy of VoucherRedeemFormEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$VoucherRedeemFormEventCopyWith<VoucherRedeemFormEvent> get copyWith => _$VoucherRedeemFormEventCopyWithImpl<VoucherRedeemFormEvent>(this as VoucherRedeemFormEvent, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
final _this = this as VoucherRedeemFormEvent;
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is VoucherRedeemFormEvent&&(identical(other.voucherId, _this.voucherId) || other.voucherId == _this.voucherId)&&(identical(other.pin, _this.pin) || other.pin == _this.pin));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
final _this = this as VoucherRedeemFormEvent;
|
||||
return Object.hash(runtimeType,_this.voucherId,_this.pin);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final _this = this as VoucherRedeemFormEvent;
|
||||
return 'VoucherRedeemFormEvent(voucherId: ${_this.voucherId}, pin: ${_this.pin})';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $VoucherRedeemFormEventCopyWith<$Res> {
|
||||
factory $VoucherRedeemFormEventCopyWith(VoucherRedeemFormEvent value, $Res Function(VoucherRedeemFormEvent) _then) = _$VoucherRedeemFormEventCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String voucherId, String pin
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$VoucherRedeemFormEventCopyWithImpl<$Res>
|
||||
implements $VoucherRedeemFormEventCopyWith<$Res> {
|
||||
_$VoucherRedeemFormEventCopyWithImpl(this._self, this._then);
|
||||
|
||||
final VoucherRedeemFormEvent _self;
|
||||
final $Res Function(VoucherRedeemFormEvent) _then;
|
||||
|
||||
/// Create a copy of VoucherRedeemFormEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? voucherId = null,Object? pin = null,}) {
|
||||
return _then(VoucherRedeemFormEvent.submitted(
|
||||
voucherId: null == voucherId ? _self.voucherId : voucherId // ignore: cast_nullable_to_non_nullable
|
||||
as String,pin: null == pin ? _self.pin : pin // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [VoucherRedeemFormEvent].
|
||||
extension VoucherRedeemFormEventPatterns on VoucherRedeemFormEvent {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( _Submitted value)? submitted,required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Submitted() when submitted != null:
|
||||
return submitted(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( _Submitted value) submitted,}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Submitted():
|
||||
return submitted(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( _Submitted value)? submitted,}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Submitted() when submitted != null:
|
||||
return submitted(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String voucherId, String pin)? submitted,required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Submitted() when submitted != null:
|
||||
return submitted(_that.voucherId,_that.pin);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String voucherId, String pin) submitted,}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Submitted():
|
||||
return submitted(_that.voucherId,_that.pin);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String voucherId, String pin)? submitted,}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Submitted() when submitted != null:
|
||||
return submitted(_that.voucherId,_that.pin);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _Submitted implements VoucherRedeemFormEvent {
|
||||
const _Submitted({required this.voucherId, required this.pin});
|
||||
|
||||
|
||||
@override final String voucherId;
|
||||
@override final String pin;
|
||||
|
||||
/// Create a copy of VoucherRedeemFormEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$SubmittedCopyWith<_Submitted> get copyWith => __$SubmittedCopyWithImpl<_Submitted>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Submitted&&(identical(other.voucherId, voucherId) || other.voucherId == voucherId)&&(identical(other.pin, pin) || other.pin == pin));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(runtimeType,voucherId,pin);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'VoucherRedeemFormEvent.submitted(voucherId: $voucherId, pin: $pin)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$SubmittedCopyWith<$Res> implements $VoucherRedeemFormEventCopyWith<$Res> {
|
||||
factory _$SubmittedCopyWith(_Submitted value, $Res Function(_Submitted) _then) = __$SubmittedCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String voucherId, String pin
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$SubmittedCopyWithImpl<$Res>
|
||||
implements _$SubmittedCopyWith<$Res> {
|
||||
__$SubmittedCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _Submitted _self;
|
||||
final $Res Function(_Submitted) _then;
|
||||
|
||||
/// Create a copy of VoucherRedeemFormEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? voucherId = null,Object? pin = null,}) {
|
||||
return _then(_Submitted(
|
||||
voucherId: null == voucherId ? _self.voucherId : voucherId // ignore: cast_nullable_to_non_nullable
|
||||
as String,pin: null == pin ? _self.pin : pin // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$VoucherRedeemFormState {
|
||||
|
||||
VoucherRedeemResult? get result; Option<WalletFailure> get failureOption; bool get isSubmitting; String? get idempotencyKey;
|
||||
/// Create a copy of VoucherRedeemFormState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$VoucherRedeemFormStateCopyWith<VoucherRedeemFormState> get copyWith => _$VoucherRedeemFormStateCopyWithImpl<VoucherRedeemFormState>(this as VoucherRedeemFormState, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
final _this = this as VoucherRedeemFormState;
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is VoucherRedeemFormState&&(identical(other.result, _this.result) || other.result == _this.result)&&(identical(other.failureOption, _this.failureOption) || other.failureOption == _this.failureOption)&&(identical(other.isSubmitting, _this.isSubmitting) || other.isSubmitting == _this.isSubmitting)&&(identical(other.idempotencyKey, _this.idempotencyKey) || other.idempotencyKey == _this.idempotencyKey));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
final _this = this as VoucherRedeemFormState;
|
||||
return Object.hash(runtimeType,_this.result,_this.failureOption,_this.isSubmitting,_this.idempotencyKey);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final _this = this as VoucherRedeemFormState;
|
||||
return 'VoucherRedeemFormState(result: ${_this.result}, failureOption: ${_this.failureOption}, isSubmitting: ${_this.isSubmitting}, idempotencyKey: ${_this.idempotencyKey})';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $VoucherRedeemFormStateCopyWith<$Res> {
|
||||
factory $VoucherRedeemFormStateCopyWith(VoucherRedeemFormState value, $Res Function(VoucherRedeemFormState) _then) = _$VoucherRedeemFormStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
VoucherRedeemResult? result, Option<WalletFailure> failureOption, bool isSubmitting, String? idempotencyKey
|
||||
});
|
||||
|
||||
|
||||
$VoucherRedeemResultCopyWith<$Res>? get result;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$VoucherRedeemFormStateCopyWithImpl<$Res>
|
||||
implements $VoucherRedeemFormStateCopyWith<$Res> {
|
||||
_$VoucherRedeemFormStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final VoucherRedeemFormState _self;
|
||||
final $Res Function(VoucherRedeemFormState) _then;
|
||||
|
||||
/// Create a copy of VoucherRedeemFormState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? result = freezed,Object? failureOption = null,Object? isSubmitting = null,Object? idempotencyKey = freezed,}) {
|
||||
return _then(VoucherRedeemFormState(
|
||||
result: freezed == result ? _self.result : result // ignore: cast_nullable_to_non_nullable
|
||||
as VoucherRedeemResult?,failureOption: null == failureOption ? _self.failureOption : failureOption // ignore: cast_nullable_to_non_nullable
|
||||
as Option<WalletFailure>,isSubmitting: null == isSubmitting ? _self.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable
|
||||
as bool,idempotencyKey: freezed == idempotencyKey ? _self.idempotencyKey : idempotencyKey // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
/// Create a copy of VoucherRedeemFormState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$VoucherRedeemResultCopyWith<$Res>? get result {
|
||||
if (_self.result == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $VoucherRedeemResultCopyWith<$Res>(_self.result!, (value) {
|
||||
return _then(_self.copyWith(result: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [VoucherRedeemFormState].
|
||||
extension VoucherRedeemFormStatePatterns on VoucherRedeemFormState {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _VoucherRedeemFormState value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _VoucherRedeemFormState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _VoucherRedeemFormState value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _VoucherRedeemFormState():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _VoucherRedeemFormState value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _VoucherRedeemFormState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( VoucherRedeemResult? result, Option<WalletFailure> failureOption, bool isSubmitting, String? idempotencyKey)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _VoucherRedeemFormState() when $default != null:
|
||||
return $default(_that.result,_that.failureOption,_that.isSubmitting,_that.idempotencyKey);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( VoucherRedeemResult? result, Option<WalletFailure> failureOption, bool isSubmitting, String? idempotencyKey) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _VoucherRedeemFormState():
|
||||
return $default(_that.result,_that.failureOption,_that.isSubmitting,_that.idempotencyKey);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( VoucherRedeemResult? result, Option<WalletFailure> failureOption, bool isSubmitting, String? idempotencyKey)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _VoucherRedeemFormState() when $default != null:
|
||||
return $default(_that.result,_that.failureOption,_that.isSubmitting,_that.idempotencyKey);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _VoucherRedeemFormState implements VoucherRedeemFormState {
|
||||
const _VoucherRedeemFormState({this.result, required this.failureOption, this.isSubmitting = false, this.idempotencyKey});
|
||||
|
||||
|
||||
@override final VoucherRedeemResult? result;
|
||||
@override final Option<WalletFailure> failureOption;
|
||||
@override@JsonKey() final bool isSubmitting;
|
||||
@override final String? idempotencyKey;
|
||||
|
||||
/// Create a copy of VoucherRedeemFormState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$VoucherRedeemFormStateCopyWith<_VoucherRedeemFormState> get copyWith => __$VoucherRedeemFormStateCopyWithImpl<_VoucherRedeemFormState>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _VoucherRedeemFormState&&(identical(other.result, result) || other.result == result)&&(identical(other.failureOption, failureOption) || other.failureOption == failureOption)&&(identical(other.isSubmitting, isSubmitting) || other.isSubmitting == isSubmitting)&&(identical(other.idempotencyKey, idempotencyKey) || other.idempotencyKey == idempotencyKey));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(runtimeType,result,failureOption,isSubmitting,idempotencyKey);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'VoucherRedeemFormState(result: $result, failureOption: $failureOption, isSubmitting: $isSubmitting, idempotencyKey: $idempotencyKey)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$VoucherRedeemFormStateCopyWith<$Res> implements $VoucherRedeemFormStateCopyWith<$Res> {
|
||||
factory _$VoucherRedeemFormStateCopyWith(_VoucherRedeemFormState value, $Res Function(_VoucherRedeemFormState) _then) = __$VoucherRedeemFormStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
VoucherRedeemResult? result, Option<WalletFailure> failureOption, bool isSubmitting, String? idempotencyKey
|
||||
});
|
||||
|
||||
|
||||
@override $VoucherRedeemResultCopyWith<$Res>? get result;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$VoucherRedeemFormStateCopyWithImpl<$Res>
|
||||
implements _$VoucherRedeemFormStateCopyWith<$Res> {
|
||||
__$VoucherRedeemFormStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _VoucherRedeemFormState _self;
|
||||
final $Res Function(_VoucherRedeemFormState) _then;
|
||||
|
||||
/// Create a copy of VoucherRedeemFormState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? result = freezed,Object? failureOption = null,Object? isSubmitting = null,Object? idempotencyKey = freezed,}) {
|
||||
return _then(_VoucherRedeemFormState(
|
||||
result: freezed == result ? _self.result : result // ignore: cast_nullable_to_non_nullable
|
||||
as VoucherRedeemResult?,failureOption: null == failureOption ? _self.failureOption : failureOption // ignore: cast_nullable_to_non_nullable
|
||||
as Option<WalletFailure>,isSubmitting: null == isSubmitting ? _self.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable
|
||||
as bool,idempotencyKey: freezed == idempotencyKey ? _self.idempotencyKey : idempotencyKey // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
/// Create a copy of VoucherRedeemFormState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$VoucherRedeemResultCopyWith<$Res>? get result {
|
||||
if (_self.result == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $VoucherRedeemResultCopyWith<$Res>(_self.result!, (value) {
|
||||
return _then(_self.copyWith(result: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -0,0 +1,10 @@
|
||||
part of 'voucher_redeem_form_bloc.dart';
|
||||
|
||||
@freezed
|
||||
abstract class VoucherRedeemFormEvent with _$VoucherRedeemFormEvent {
|
||||
/// [pin] hanya diteruskan ke API, tidak disimpan di state.
|
||||
const factory VoucherRedeemFormEvent.submitted({
|
||||
required String voucherId,
|
||||
required String pin,
|
||||
}) = _Submitted;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
part of 'voucher_redeem_form_bloc.dart';
|
||||
|
||||
@freezed
|
||||
abstract class VoucherRedeemFormState with _$VoucherRedeemFormState {
|
||||
const factory VoucherRedeemFormState({
|
||||
VoucherRedeemResult? result,
|
||||
required Option<WalletFailure> failureOption,
|
||||
@Default(false) bool isSubmitting,
|
||||
String? idempotencyKey,
|
||||
}) = _VoucherRedeemFormState;
|
||||
|
||||
factory VoucherRedeemFormState.initial() =>
|
||||
VoucherRedeemFormState(failureOption: none());
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import '../../../domain/wallet/wallet.dart';
|
||||
|
||||
part 'voucher_redemption_loader_event.dart';
|
||||
part 'voucher_redemption_loader_state.dart';
|
||||
part 'voucher_redemption_loader_bloc.freezed.dart';
|
||||
|
||||
/// "Voucher saya" (§9.3).
|
||||
@injectable
|
||||
class VoucherRedemptionLoaderBloc
|
||||
extends Bloc<VoucherRedemptionLoaderEvent, VoucherRedemptionLoaderState> {
|
||||
final IVoucherRepository _repository;
|
||||
|
||||
static const int _limit = 20;
|
||||
|
||||
VoucherRedemptionLoaderBloc(this._repository)
|
||||
: super(VoucherRedemptionLoaderState.initial()) {
|
||||
on<VoucherRedemptionLoaderEvent>(_onVoucherRedemptionLoaderEvent);
|
||||
}
|
||||
|
||||
Future<void> _onVoucherRedemptionLoaderEvent(
|
||||
VoucherRedemptionLoaderEvent event,
|
||||
Emitter<VoucherRedemptionLoaderState> emit,
|
||||
) {
|
||||
return event.map(
|
||||
refreshed: (e) async {
|
||||
emit(state.copyWith(isFetching: true, failureOption: none()));
|
||||
|
||||
final result = await _repository.getRedemptions(page: 1, limit: _limit);
|
||||
|
||||
emit(
|
||||
result
|
||||
.fold(
|
||||
(f) => state.copyWith(failureOption: optionOf(f)),
|
||||
(page) => state.copyWith(
|
||||
items: page.items,
|
||||
page: page.page,
|
||||
totalPages: page.totalPages,
|
||||
hasLoaded: true,
|
||||
),
|
||||
)
|
||||
.copyWith(isFetching: false),
|
||||
);
|
||||
},
|
||||
loadedMore: (e) async {
|
||||
if (state.isFetching || state.isLoadingMore || !state.hasMore) return;
|
||||
emit(state.copyWith(isLoadingMore: true, failureOption: none()));
|
||||
|
||||
final result = await _repository.getRedemptions(
|
||||
page: state.page + 1,
|
||||
limit: _limit,
|
||||
);
|
||||
|
||||
emit(
|
||||
result
|
||||
.fold(
|
||||
(f) => state.copyWith(failureOption: optionOf(f)),
|
||||
(page) => state.copyWith(
|
||||
items: [...state.items, ...page.items],
|
||||
page: page.page,
|
||||
totalPages: page.totalPages,
|
||||
),
|
||||
)
|
||||
.copyWith(isLoadingMore: false),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+533
@@ -0,0 +1,533 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'voucher_redemption_loader_bloc.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$VoucherRedemptionLoaderEvent {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is VoucherRedemptionLoaderEvent);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => runtimeType.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'VoucherRedemptionLoaderEvent()';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class $VoucherRedemptionLoaderEventCopyWith<$Res> {
|
||||
$VoucherRedemptionLoaderEventCopyWith(VoucherRedemptionLoaderEvent _, $Res Function(VoucherRedemptionLoaderEvent) __);
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [VoucherRedemptionLoaderEvent].
|
||||
extension VoucherRedemptionLoaderEventPatterns on VoucherRedemptionLoaderEvent {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( _Refreshed value)? refreshed,TResult Function( _LoadedMore value)? loadedMore,required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Refreshed() when refreshed != null:
|
||||
return refreshed(_that);case _LoadedMore() when loadedMore != null:
|
||||
return loadedMore(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( _Refreshed value) refreshed,required TResult Function( _LoadedMore value) loadedMore,}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Refreshed():
|
||||
return refreshed(_that);case _LoadedMore():
|
||||
return loadedMore(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( _Refreshed value)? refreshed,TResult? Function( _LoadedMore value)? loadedMore,}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Refreshed() when refreshed != null:
|
||||
return refreshed(_that);case _LoadedMore() when loadedMore != null:
|
||||
return loadedMore(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function()? refreshed,TResult Function()? loadedMore,required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Refreshed() when refreshed != null:
|
||||
return refreshed();case _LoadedMore() when loadedMore != null:
|
||||
return loadedMore();case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function() refreshed,required TResult Function() loadedMore,}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Refreshed():
|
||||
return refreshed();case _LoadedMore():
|
||||
return loadedMore();case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function()? refreshed,TResult? Function()? loadedMore,}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Refreshed() when refreshed != null:
|
||||
return refreshed();case _LoadedMore() when loadedMore != null:
|
||||
return loadedMore();case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _Refreshed implements VoucherRedemptionLoaderEvent {
|
||||
const _Refreshed();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Refreshed);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => runtimeType.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'VoucherRedemptionLoaderEvent.refreshed()';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _LoadedMore implements VoucherRedemptionLoaderEvent {
|
||||
const _LoadedMore();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoadedMore);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => runtimeType.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'VoucherRedemptionLoaderEvent.loadedMore()';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$VoucherRedemptionLoaderState {
|
||||
|
||||
List<VoucherRedemption> get items; int get page; int get totalPages; Option<WalletFailure> get failureOption; bool get isFetching; bool get isLoadingMore; bool get hasLoaded;
|
||||
/// Create a copy of VoucherRedemptionLoaderState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$VoucherRedemptionLoaderStateCopyWith<VoucherRedemptionLoaderState> get copyWith => _$VoucherRedemptionLoaderStateCopyWithImpl<VoucherRedemptionLoaderState>(this as VoucherRedemptionLoaderState, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
final _this = this as VoucherRedemptionLoaderState;
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is VoucherRedemptionLoaderState&&const DeepCollectionEquality().equals(other.items, _this.items)&&(identical(other.page, _this.page) || other.page == _this.page)&&(identical(other.totalPages, _this.totalPages) || other.totalPages == _this.totalPages)&&(identical(other.failureOption, _this.failureOption) || other.failureOption == _this.failureOption)&&(identical(other.isFetching, _this.isFetching) || other.isFetching == _this.isFetching)&&(identical(other.isLoadingMore, _this.isLoadingMore) || other.isLoadingMore == _this.isLoadingMore)&&(identical(other.hasLoaded, _this.hasLoaded) || other.hasLoaded == _this.hasLoaded));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
final _this = this as VoucherRedemptionLoaderState;
|
||||
return Object.hash(runtimeType,const DeepCollectionEquality().hash(_this.items),_this.page,_this.totalPages,_this.failureOption,_this.isFetching,_this.isLoadingMore,_this.hasLoaded);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final _this = this as VoucherRedemptionLoaderState;
|
||||
return 'VoucherRedemptionLoaderState(items: ${_this.items}, page: ${_this.page}, totalPages: ${_this.totalPages}, failureOption: ${_this.failureOption}, isFetching: ${_this.isFetching}, isLoadingMore: ${_this.isLoadingMore}, hasLoaded: ${_this.hasLoaded})';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $VoucherRedemptionLoaderStateCopyWith<$Res> {
|
||||
factory $VoucherRedemptionLoaderStateCopyWith(VoucherRedemptionLoaderState value, $Res Function(VoucherRedemptionLoaderState) _then) = _$VoucherRedemptionLoaderStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
List<VoucherRedemption> items, int page, int totalPages, Option<WalletFailure> failureOption, bool isFetching, bool isLoadingMore, bool hasLoaded
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$VoucherRedemptionLoaderStateCopyWithImpl<$Res>
|
||||
implements $VoucherRedemptionLoaderStateCopyWith<$Res> {
|
||||
_$VoucherRedemptionLoaderStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final VoucherRedemptionLoaderState _self;
|
||||
final $Res Function(VoucherRedemptionLoaderState) _then;
|
||||
|
||||
/// Create a copy of VoucherRedemptionLoaderState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? items = null,Object? page = null,Object? totalPages = null,Object? failureOption = null,Object? isFetching = null,Object? isLoadingMore = null,Object? hasLoaded = null,}) {
|
||||
return _then(VoucherRedemptionLoaderState(
|
||||
items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable
|
||||
as List<VoucherRedemption>,page: null == page ? _self.page : page // ignore: cast_nullable_to_non_nullable
|
||||
as int,totalPages: null == totalPages ? _self.totalPages : totalPages // ignore: cast_nullable_to_non_nullable
|
||||
as int,failureOption: null == failureOption ? _self.failureOption : failureOption // ignore: cast_nullable_to_non_nullable
|
||||
as Option<WalletFailure>,isFetching: null == isFetching ? _self.isFetching : isFetching // ignore: cast_nullable_to_non_nullable
|
||||
as bool,isLoadingMore: null == isLoadingMore ? _self.isLoadingMore : isLoadingMore // ignore: cast_nullable_to_non_nullable
|
||||
as bool,hasLoaded: null == hasLoaded ? _self.hasLoaded : hasLoaded // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [VoucherRedemptionLoaderState].
|
||||
extension VoucherRedemptionLoaderStatePatterns on VoucherRedemptionLoaderState {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _VoucherRedemptionLoaderState value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _VoucherRedemptionLoaderState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _VoucherRedemptionLoaderState value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _VoucherRedemptionLoaderState():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _VoucherRedemptionLoaderState value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _VoucherRedemptionLoaderState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<VoucherRedemption> items, int page, int totalPages, Option<WalletFailure> failureOption, bool isFetching, bool isLoadingMore, bool hasLoaded)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _VoucherRedemptionLoaderState() when $default != null:
|
||||
return $default(_that.items,_that.page,_that.totalPages,_that.failureOption,_that.isFetching,_that.isLoadingMore,_that.hasLoaded);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<VoucherRedemption> items, int page, int totalPages, Option<WalletFailure> failureOption, bool isFetching, bool isLoadingMore, bool hasLoaded) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _VoucherRedemptionLoaderState():
|
||||
return $default(_that.items,_that.page,_that.totalPages,_that.failureOption,_that.isFetching,_that.isLoadingMore,_that.hasLoaded);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<VoucherRedemption> items, int page, int totalPages, Option<WalletFailure> failureOption, bool isFetching, bool isLoadingMore, bool hasLoaded)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _VoucherRedemptionLoaderState() when $default != null:
|
||||
return $default(_that.items,_that.page,_that.totalPages,_that.failureOption,_that.isFetching,_that.isLoadingMore,_that.hasLoaded);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _VoucherRedemptionLoaderState extends VoucherRedemptionLoaderState {
|
||||
const _VoucherRedemptionLoaderState({required List<VoucherRedemption> items, required this.page, required this.totalPages, required this.failureOption, this.isFetching = false, this.isLoadingMore = false, this.hasLoaded = false}): _items = items,super._();
|
||||
|
||||
|
||||
final List<VoucherRedemption> _items;
|
||||
@override List<VoucherRedemption> get items {
|
||||
if (_items is EqualUnmodifiableListView) return _items;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_items);
|
||||
}
|
||||
|
||||
@override final int page;
|
||||
@override final int totalPages;
|
||||
@override final Option<WalletFailure> failureOption;
|
||||
@override@JsonKey() final bool isFetching;
|
||||
@override@JsonKey() final bool isLoadingMore;
|
||||
@override@JsonKey() final bool hasLoaded;
|
||||
|
||||
/// Create a copy of VoucherRedemptionLoaderState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$VoucherRedemptionLoaderStateCopyWith<_VoucherRedemptionLoaderState> get copyWith => __$VoucherRedemptionLoaderStateCopyWithImpl<_VoucherRedemptionLoaderState>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _VoucherRedemptionLoaderState&&const DeepCollectionEquality().equals(other.items, _items)&&(identical(other.page, page) || other.page == page)&&(identical(other.totalPages, totalPages) || other.totalPages == totalPages)&&(identical(other.failureOption, failureOption) || other.failureOption == failureOption)&&(identical(other.isFetching, isFetching) || other.isFetching == isFetching)&&(identical(other.isLoadingMore, isLoadingMore) || other.isLoadingMore == isLoadingMore)&&(identical(other.hasLoaded, hasLoaded) || other.hasLoaded == hasLoaded));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(runtimeType,const DeepCollectionEquality().hash(_items),page,totalPages,failureOption,isFetching,isLoadingMore,hasLoaded);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'VoucherRedemptionLoaderState(items: $items, page: $page, totalPages: $totalPages, failureOption: $failureOption, isFetching: $isFetching, isLoadingMore: $isLoadingMore, hasLoaded: $hasLoaded)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$VoucherRedemptionLoaderStateCopyWith<$Res> implements $VoucherRedemptionLoaderStateCopyWith<$Res> {
|
||||
factory _$VoucherRedemptionLoaderStateCopyWith(_VoucherRedemptionLoaderState value, $Res Function(_VoucherRedemptionLoaderState) _then) = __$VoucherRedemptionLoaderStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
List<VoucherRedemption> items, int page, int totalPages, Option<WalletFailure> failureOption, bool isFetching, bool isLoadingMore, bool hasLoaded
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$VoucherRedemptionLoaderStateCopyWithImpl<$Res>
|
||||
implements _$VoucherRedemptionLoaderStateCopyWith<$Res> {
|
||||
__$VoucherRedemptionLoaderStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _VoucherRedemptionLoaderState _self;
|
||||
final $Res Function(_VoucherRedemptionLoaderState) _then;
|
||||
|
||||
/// Create a copy of VoucherRedemptionLoaderState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? items = null,Object? page = null,Object? totalPages = null,Object? failureOption = null,Object? isFetching = null,Object? isLoadingMore = null,Object? hasLoaded = null,}) {
|
||||
return _then(_VoucherRedemptionLoaderState(
|
||||
items: null == items ? _self._items : items // ignore: cast_nullable_to_non_nullable
|
||||
as List<VoucherRedemption>,page: null == page ? _self.page : page // ignore: cast_nullable_to_non_nullable
|
||||
as int,totalPages: null == totalPages ? _self.totalPages : totalPages // ignore: cast_nullable_to_non_nullable
|
||||
as int,failureOption: null == failureOption ? _self.failureOption : failureOption // ignore: cast_nullable_to_non_nullable
|
||||
as Option<WalletFailure>,isFetching: null == isFetching ? _self.isFetching : isFetching // ignore: cast_nullable_to_non_nullable
|
||||
as bool,isLoadingMore: null == isLoadingMore ? _self.isLoadingMore : isLoadingMore // ignore: cast_nullable_to_non_nullable
|
||||
as bool,hasLoaded: null == hasLoaded ? _self.hasLoaded : hasLoaded // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
part of 'voucher_redemption_loader_bloc.dart';
|
||||
|
||||
@freezed
|
||||
abstract class VoucherRedemptionLoaderEvent
|
||||
with _$VoucherRedemptionLoaderEvent {
|
||||
/// Muat ulang dari halaman pertama.
|
||||
const factory VoucherRedemptionLoaderEvent.refreshed() = _Refreshed;
|
||||
|
||||
const factory VoucherRedemptionLoaderEvent.loadedMore() = _LoadedMore;
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
part of 'voucher_redemption_loader_bloc.dart';
|
||||
|
||||
@freezed
|
||||
abstract class VoucherRedemptionLoaderState
|
||||
with _$VoucherRedemptionLoaderState {
|
||||
const VoucherRedemptionLoaderState._();
|
||||
|
||||
const factory VoucherRedemptionLoaderState({
|
||||
required List<VoucherRedemption> items,
|
||||
required int page,
|
||||
required int totalPages,
|
||||
required Option<WalletFailure> failureOption,
|
||||
@Default(false) bool isFetching,
|
||||
@Default(false) bool isLoadingMore,
|
||||
@Default(false) bool hasLoaded,
|
||||
}) = _VoucherRedemptionLoaderState;
|
||||
|
||||
factory VoucherRedemptionLoaderState.initial() =>
|
||||
VoucherRedemptionLoaderState(
|
||||
items: const [],
|
||||
page: 0,
|
||||
totalPages: 0,
|
||||
failureOption: none(),
|
||||
);
|
||||
|
||||
bool get hasMore => page < totalPages;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import '../../../domain/wallet/wallet.dart';
|
||||
|
||||
part 'payment_code_form_event.dart';
|
||||
part 'payment_code_form_state.dart';
|
||||
part 'payment_code_form_bloc.freezed.dart';
|
||||
|
||||
@injectable
|
||||
class PaymentCodeFormBloc
|
||||
extends Bloc<PaymentCodeFormEvent, PaymentCodeFormState> {
|
||||
final IWalletRepository _repository;
|
||||
|
||||
PaymentCodeFormBloc(this._repository)
|
||||
: super(PaymentCodeFormState.initial()) {
|
||||
on<PaymentCodeFormEvent>(_onPaymentCodeFormEvent);
|
||||
}
|
||||
|
||||
Future<void> _onPaymentCodeFormEvent(
|
||||
PaymentCodeFormEvent event,
|
||||
Emitter<PaymentCodeFormState> emit,
|
||||
) {
|
||||
return event.map(
|
||||
submitted: (e) async {
|
||||
emit(state.copyWith(isSubmitting: true, failureOption: none()));
|
||||
|
||||
final result = await _repository.createPaymentCode(pin: e.pin);
|
||||
|
||||
emit(
|
||||
result
|
||||
.fold(
|
||||
(f) => state.copyWith(failureOption: optionOf(f)),
|
||||
(code) => state.copyWith(paymentCode: code),
|
||||
)
|
||||
.copyWith(isSubmitting: false),
|
||||
);
|
||||
},
|
||||
expired: (e) async => emit(state.copyWith(paymentCode: null)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,575 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'payment_code_form_bloc.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$PaymentCodeFormEvent {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is PaymentCodeFormEvent);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => runtimeType.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PaymentCodeFormEvent()';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class $PaymentCodeFormEventCopyWith<$Res> {
|
||||
$PaymentCodeFormEventCopyWith(PaymentCodeFormEvent _, $Res Function(PaymentCodeFormEvent) __);
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [PaymentCodeFormEvent].
|
||||
extension PaymentCodeFormEventPatterns on PaymentCodeFormEvent {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( _Submitted value)? submitted,TResult Function( _Expired value)? expired,required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Submitted() when submitted != null:
|
||||
return submitted(_that);case _Expired() when expired != null:
|
||||
return expired(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( _Submitted value) submitted,required TResult Function( _Expired value) expired,}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Submitted():
|
||||
return submitted(_that);case _Expired():
|
||||
return expired(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( _Submitted value)? submitted,TResult? Function( _Expired value)? expired,}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Submitted() when submitted != null:
|
||||
return submitted(_that);case _Expired() when expired != null:
|
||||
return expired(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String pin)? submitted,TResult Function()? expired,required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Submitted() when submitted != null:
|
||||
return submitted(_that.pin);case _Expired() when expired != null:
|
||||
return expired();case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String pin) submitted,required TResult Function() expired,}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Submitted():
|
||||
return submitted(_that.pin);case _Expired():
|
||||
return expired();case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String pin)? submitted,TResult? Function()? expired,}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Submitted() when submitted != null:
|
||||
return submitted(_that.pin);case _Expired() when expired != null:
|
||||
return expired();case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _Submitted implements PaymentCodeFormEvent {
|
||||
const _Submitted(this.pin);
|
||||
|
||||
|
||||
final String pin;
|
||||
|
||||
/// Create a copy of PaymentCodeFormEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$SubmittedCopyWith<_Submitted> get copyWith => __$SubmittedCopyWithImpl<_Submitted>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Submitted&&(identical(other.pin, pin) || other.pin == pin));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(runtimeType,pin);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PaymentCodeFormEvent.submitted(pin: $pin)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$SubmittedCopyWith<$Res> implements $PaymentCodeFormEventCopyWith<$Res> {
|
||||
factory _$SubmittedCopyWith(_Submitted value, $Res Function(_Submitted) _then) = __$SubmittedCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String pin
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$SubmittedCopyWithImpl<$Res>
|
||||
implements _$SubmittedCopyWith<$Res> {
|
||||
__$SubmittedCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _Submitted _self;
|
||||
final $Res Function(_Submitted) _then;
|
||||
|
||||
/// Create a copy of PaymentCodeFormEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') $Res call({Object? pin = null,}) {
|
||||
return _then(_Submitted(
|
||||
null == pin ? _self.pin : pin // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _Expired implements PaymentCodeFormEvent {
|
||||
const _Expired();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Expired);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => runtimeType.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PaymentCodeFormEvent.expired()';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$PaymentCodeFormState {
|
||||
|
||||
PaymentCode? get paymentCode; Option<WalletFailure> get failureOption; bool get isSubmitting;
|
||||
/// Create a copy of PaymentCodeFormState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$PaymentCodeFormStateCopyWith<PaymentCodeFormState> get copyWith => _$PaymentCodeFormStateCopyWithImpl<PaymentCodeFormState>(this as PaymentCodeFormState, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
final _this = this as PaymentCodeFormState;
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is PaymentCodeFormState&&(identical(other.paymentCode, _this.paymentCode) || other.paymentCode == _this.paymentCode)&&(identical(other.failureOption, _this.failureOption) || other.failureOption == _this.failureOption)&&(identical(other.isSubmitting, _this.isSubmitting) || other.isSubmitting == _this.isSubmitting));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
final _this = this as PaymentCodeFormState;
|
||||
return Object.hash(runtimeType,_this.paymentCode,_this.failureOption,_this.isSubmitting);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final _this = this as PaymentCodeFormState;
|
||||
return 'PaymentCodeFormState(paymentCode: ${_this.paymentCode}, failureOption: ${_this.failureOption}, isSubmitting: ${_this.isSubmitting})';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $PaymentCodeFormStateCopyWith<$Res> {
|
||||
factory $PaymentCodeFormStateCopyWith(PaymentCodeFormState value, $Res Function(PaymentCodeFormState) _then) = _$PaymentCodeFormStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
PaymentCode? paymentCode, Option<WalletFailure> failureOption, bool isSubmitting
|
||||
});
|
||||
|
||||
|
||||
$PaymentCodeCopyWith<$Res>? get paymentCode;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$PaymentCodeFormStateCopyWithImpl<$Res>
|
||||
implements $PaymentCodeFormStateCopyWith<$Res> {
|
||||
_$PaymentCodeFormStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final PaymentCodeFormState _self;
|
||||
final $Res Function(PaymentCodeFormState) _then;
|
||||
|
||||
/// Create a copy of PaymentCodeFormState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? paymentCode = freezed,Object? failureOption = null,Object? isSubmitting = null,}) {
|
||||
return _then(PaymentCodeFormState(
|
||||
paymentCode: freezed == paymentCode ? _self.paymentCode : paymentCode // ignore: cast_nullable_to_non_nullable
|
||||
as PaymentCode?,failureOption: null == failureOption ? _self.failureOption : failureOption // ignore: cast_nullable_to_non_nullable
|
||||
as Option<WalletFailure>,isSubmitting: null == isSubmitting ? _self.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
/// Create a copy of PaymentCodeFormState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$PaymentCodeCopyWith<$Res>? get paymentCode {
|
||||
if (_self.paymentCode == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $PaymentCodeCopyWith<$Res>(_self.paymentCode!, (value) {
|
||||
return _then(_self.copyWith(paymentCode: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [PaymentCodeFormState].
|
||||
extension PaymentCodeFormStatePatterns on PaymentCodeFormState {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _PaymentCodeFormState value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _PaymentCodeFormState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _PaymentCodeFormState value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _PaymentCodeFormState():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _PaymentCodeFormState value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _PaymentCodeFormState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( PaymentCode? paymentCode, Option<WalletFailure> failureOption, bool isSubmitting)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _PaymentCodeFormState() when $default != null:
|
||||
return $default(_that.paymentCode,_that.failureOption,_that.isSubmitting);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( PaymentCode? paymentCode, Option<WalletFailure> failureOption, bool isSubmitting) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _PaymentCodeFormState():
|
||||
return $default(_that.paymentCode,_that.failureOption,_that.isSubmitting);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( PaymentCode? paymentCode, Option<WalletFailure> failureOption, bool isSubmitting)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _PaymentCodeFormState() when $default != null:
|
||||
return $default(_that.paymentCode,_that.failureOption,_that.isSubmitting);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _PaymentCodeFormState implements PaymentCodeFormState {
|
||||
const _PaymentCodeFormState({this.paymentCode, required this.failureOption, this.isSubmitting = false});
|
||||
|
||||
|
||||
@override final PaymentCode? paymentCode;
|
||||
@override final Option<WalletFailure> failureOption;
|
||||
@override@JsonKey() final bool isSubmitting;
|
||||
|
||||
/// Create a copy of PaymentCodeFormState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$PaymentCodeFormStateCopyWith<_PaymentCodeFormState> get copyWith => __$PaymentCodeFormStateCopyWithImpl<_PaymentCodeFormState>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PaymentCodeFormState&&(identical(other.paymentCode, paymentCode) || other.paymentCode == paymentCode)&&(identical(other.failureOption, failureOption) || other.failureOption == failureOption)&&(identical(other.isSubmitting, isSubmitting) || other.isSubmitting == isSubmitting));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(runtimeType,paymentCode,failureOption,isSubmitting);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PaymentCodeFormState(paymentCode: $paymentCode, failureOption: $failureOption, isSubmitting: $isSubmitting)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$PaymentCodeFormStateCopyWith<$Res> implements $PaymentCodeFormStateCopyWith<$Res> {
|
||||
factory _$PaymentCodeFormStateCopyWith(_PaymentCodeFormState value, $Res Function(_PaymentCodeFormState) _then) = __$PaymentCodeFormStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
PaymentCode? paymentCode, Option<WalletFailure> failureOption, bool isSubmitting
|
||||
});
|
||||
|
||||
|
||||
@override $PaymentCodeCopyWith<$Res>? get paymentCode;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$PaymentCodeFormStateCopyWithImpl<$Res>
|
||||
implements _$PaymentCodeFormStateCopyWith<$Res> {
|
||||
__$PaymentCodeFormStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _PaymentCodeFormState _self;
|
||||
final $Res Function(_PaymentCodeFormState) _then;
|
||||
|
||||
/// Create a copy of PaymentCodeFormState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? paymentCode = freezed,Object? failureOption = null,Object? isSubmitting = null,}) {
|
||||
return _then(_PaymentCodeFormState(
|
||||
paymentCode: freezed == paymentCode ? _self.paymentCode : paymentCode // ignore: cast_nullable_to_non_nullable
|
||||
as PaymentCode?,failureOption: null == failureOption ? _self.failureOption : failureOption // ignore: cast_nullable_to_non_nullable
|
||||
as Option<WalletFailure>,isSubmitting: null == isSubmitting ? _self.isSubmitting : isSubmitting // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
/// Create a copy of PaymentCodeFormState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$PaymentCodeCopyWith<$Res>? get paymentCode {
|
||||
if (_self.paymentCode == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $PaymentCodeCopyWith<$Res>(_self.paymentCode!, (value) {
|
||||
return _then(_self.copyWith(paymentCode: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -1,9 +0,0 @@
|
||||
part of 'payment_code_form_bloc.dart';
|
||||
|
||||
@freezed
|
||||
abstract class PaymentCodeFormEvent with _$PaymentCodeFormEvent {
|
||||
/// [pin] hanya diteruskan ke API, tidak disimpan di state.
|
||||
const factory PaymentCodeFormEvent.submitted(String pin) = _Submitted;
|
||||
|
||||
const factory PaymentCodeFormEvent.expired() = _Expired;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
part of 'payment_code_form_bloc.dart';
|
||||
|
||||
@freezed
|
||||
abstract class PaymentCodeFormState with _$PaymentCodeFormState {
|
||||
const factory PaymentCodeFormState({
|
||||
PaymentCode? paymentCode,
|
||||
required Option<WalletFailure> failureOption,
|
||||
@Default(false) bool isSubmitting,
|
||||
}) = _PaymentCodeFormState;
|
||||
|
||||
factory PaymentCodeFormState.initial() =>
|
||||
PaymentCodeFormState(failureOption: none());
|
||||
}
|
||||
@@ -27,7 +27,16 @@ class WalletHistoryLoaderBloc
|
||||
) {
|
||||
return event.map(
|
||||
currencyChanged: (e) async {
|
||||
emit(state.copyWith(currency: e.currency));
|
||||
// Tipe yang tidak dimiliki mata uang ini (mis. Main game di tab
|
||||
// EnakPoint) dibuang dari filter.
|
||||
emit(
|
||||
state.copyWith(
|
||||
currency: e.currency,
|
||||
types: state.types
|
||||
.where((type) => type.currencies.contains(e.currency))
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
await _fetchFirstPage(emit);
|
||||
},
|
||||
filterChanged: (e) async {
|
||||
|
||||
@@ -4,7 +4,8 @@ part of 'wallet_loader_bloc.dart';
|
||||
abstract class WalletLoaderEvent with _$WalletLoaderEvent {
|
||||
const factory WalletLoaderEvent.fetched() = _Fetched;
|
||||
|
||||
/// Saldo EnakCoin terbaru dari hasil main game (`coins_remaining`).
|
||||
/// Saldo EnakCoin terbaru yang dikirim game lewat bridge
|
||||
/// (`balance_changed`).
|
||||
const factory WalletLoaderEvent.coinBalanceUpdated(int coinBalance) =
|
||||
_CoinBalanceUpdated;
|
||||
|
||||
|
||||
@@ -1,40 +1,55 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../injection.dart';
|
||||
import '../../../presentation/router/app_router.dart';
|
||||
import '../../../presentation/router/app_router.gr.dart';
|
||||
import '../../constant/local_storage_key.dart';
|
||||
import '../errors/unauthorized_error.dart';
|
||||
|
||||
/// Token login ditolak server: HTTP `401`, atau `304` dengan `entity`
|
||||
/// `auth_handler` (§2.2). Belum ada refresh token, jadi customer harus login
|
||||
/// ulang.
|
||||
///
|
||||
/// Bukan `403`: `PIN_NOT_SET` dan `TRANSFER_BLOCKED` juga memakai `403`.
|
||||
bool isTokenRejected(Response response) {
|
||||
if (response.statusCode == 401) return true;
|
||||
|
||||
final body = response.data;
|
||||
if (body is! Map) return false;
|
||||
final errors = body['errors'];
|
||||
if (errors is! List || errors.isEmpty || errors.first is! Map) return false;
|
||||
|
||||
final first = errors.first as Map;
|
||||
return first['code']?.toString() == '304' &&
|
||||
first['entity'] == 'auth_handler';
|
||||
}
|
||||
|
||||
/// Menghapus sesi dan membuka halaman login bila token yang dipakai request
|
||||
/// ditolak. `validateStatus` menerima semua status, jadi pengecekan ada di
|
||||
/// [onResponse], bukan `onError`.
|
||||
class UnauthorizedInterceptor extends Interceptor {
|
||||
static final GlobalKey<NavigatorState> navigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
if (err.response?.statusCode == 401 ||
|
||||
err.response?.statusCode == 403 ||
|
||||
err.response?.statusCode == 419) {
|
||||
await _handleTokenExpired();
|
||||
return super.onError(UnauthorizedError(err, null), handler);
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) async {
|
||||
try {
|
||||
if (isTokenRejected(response)) await _handleTokenRejected(response);
|
||||
} finally {
|
||||
handler.next(response);
|
||||
}
|
||||
super.onError(err, handler);
|
||||
}
|
||||
|
||||
Future<void> _handleTokenExpired() async {
|
||||
// Clear stored token
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
Future<void> _handleTokenRejected(Response response) async {
|
||||
final prefs = getIt<SharedPreferences>();
|
||||
final token = prefs.getString(LocalStorageKey.token);
|
||||
// Hanya token yang sedang dipakai: abaikan request tanpa login dan jawaban
|
||||
// basi dari token lama setelah customer login lagi.
|
||||
final sentAuthorization = response.requestOptions.headers['Authorization'];
|
||||
if (token == null || sentAuthorization != 'Bearer $token') return;
|
||||
|
||||
log('token rejected', name: 'UnauthorizedInterceptor');
|
||||
await prefs.remove(LocalStorageKey.token);
|
||||
await prefs.remove(LocalStorageKey.user);
|
||||
await prefs.clear(); // Optional: clear all user data
|
||||
log('handleTokenExpired');
|
||||
// Navigate to login page
|
||||
final context = navigatorKey.currentContext;
|
||||
if (context != null) {
|
||||
// Option 1: Navigate and remove all previous routes
|
||||
context.router.replaceAll([LoginRoute()]);
|
||||
}
|
||||
getIt<AppRouter>().replaceAll([LoginRoute()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ class AppConstant {
|
||||
static const String poinName = "EnakPoint";
|
||||
static const String gameUrl = "https://game.enaklo.com/";
|
||||
|
||||
/// `theme-color` game.enaklo.com.
|
||||
/// Latar webview EnakGame selama halaman game dimuat (`theme-color`
|
||||
/// game.enaklo.com).
|
||||
static const Color gameBackgroundColor = Color(0xFF09090B);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
// wheel_painter.dart - Fixed implementation with consistent positioning
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../domain/game/game.dart';
|
||||
|
||||
class WheelPainter extends CustomPainter {
|
||||
final List<GamePrize> gamePrizes;
|
||||
final Color Function(GamePrize prize, int index) getPrizeColor;
|
||||
|
||||
WheelPainter({required this.gamePrizes, required this.getPrizeColor});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (gamePrizes.isEmpty) return;
|
||||
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = size.width / 2;
|
||||
final sectionAngle = 2 * math.pi / gamePrizes.length;
|
||||
|
||||
// Draw outer white border
|
||||
final outerBorderPaint = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawCircle(center, radius, outerBorderPaint);
|
||||
|
||||
// Draw blue border ring
|
||||
final blueBorderPaint = Paint()
|
||||
..color = const Color(0xFF3B82F6)
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawCircle(center, radius - 8, blueBorderPaint);
|
||||
|
||||
// Draw inner white circle
|
||||
final innerWhitePaint = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawCircle(center, radius - 20, innerWhitePaint);
|
||||
|
||||
// Draw sections - KONSISTEN dengan logic spin
|
||||
for (int i = 0; i < gamePrizes.length; i++) {
|
||||
final prize = gamePrizes[i];
|
||||
// Section 0 di top (-π/2), section 1 di kanan atas, dst (clockwise)
|
||||
final startAngle = (-math.pi / 2) + (i * sectionAngle);
|
||||
|
||||
// Section background
|
||||
final sectionPaint = Paint()
|
||||
..color = getPrizeColor(prize, i)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius - 22),
|
||||
startAngle,
|
||||
sectionAngle,
|
||||
true,
|
||||
sectionPaint,
|
||||
);
|
||||
|
||||
// Draw prize text
|
||||
final textAngle = startAngle + sectionAngle / 2;
|
||||
final textPosition = Offset(
|
||||
center.dx + (radius - 80) * math.cos(textAngle),
|
||||
center.dy + (radius - 80) * math.sin(textAngle),
|
||||
);
|
||||
|
||||
canvas.save();
|
||||
canvas.translate(textPosition.dx, textPosition.dy);
|
||||
canvas.rotate(textAngle + math.pi / 2);
|
||||
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: prize.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
textPainter.layout(maxWidth: 100);
|
||||
textPainter.paint(
|
||||
canvas,
|
||||
Offset(-textPainter.width / 2, -textPainter.height / 2),
|
||||
);
|
||||
canvas.restore();
|
||||
|
||||
// DEBUG: Draw section number
|
||||
final numberPosition = Offset(
|
||||
center.dx + (radius - 50) * math.cos(textAngle),
|
||||
center.dy + (radius - 50) * math.sin(textAngle),
|
||||
);
|
||||
canvas.drawCircle(numberPosition, 15, Paint()..color = Colors.white);
|
||||
final numberPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: i.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
numberPainter.layout();
|
||||
numberPainter.paint(
|
||||
canvas,
|
||||
Offset(
|
||||
numberPosition.dx - numberPainter.width / 2,
|
||||
numberPosition.dy - numberPainter.height / 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Draw white dots
|
||||
final dotPaint = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.fill;
|
||||
for (int i = 0; i < 24; i++) {
|
||||
final dotAngle = (2 * math.pi / 24) * i;
|
||||
final dotPosition = Offset(
|
||||
center.dx + (radius - 14) * math.cos(dotAngle),
|
||||
center.dy + (radius - 14) * math.sin(dotAngle),
|
||||
);
|
||||
canvas.drawCircle(dotPosition, 3, dotPaint);
|
||||
}
|
||||
|
||||
// Draw section dividers
|
||||
final dividerPaint = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
for (int i = 0; i < gamePrizes.length; i++) {
|
||||
final angle = (-math.pi / 2) + (i * sectionAngle);
|
||||
final lineStart = Offset(
|
||||
center.dx + (radius - 110) * math.cos(angle),
|
||||
center.dy + (radius - 110) * math.sin(angle),
|
||||
);
|
||||
final lineEnd = Offset(
|
||||
center.dx + (radius - 22) * math.cos(angle),
|
||||
center.dy + (radius - 22) * math.sin(angle),
|
||||
);
|
||||
canvas.drawLine(lineStart, lineEnd, dividerPaint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
||||
@@ -6,15 +6,10 @@ class ApiPath {
|
||||
static String login = '/api/v1/customer-auth/login';
|
||||
static String resend = '/api/v1/customer-auth/resend-otp';
|
||||
|
||||
// Marketing
|
||||
static String ferrisWheel = '/api/v1/customer/ferris-wheel';
|
||||
static String spin = '/api/v1/customer/spin';
|
||||
|
||||
// Wallet
|
||||
static String wallet = '/api/v1/customer/wallet';
|
||||
static String walletTransactions = '/api/v1/customer/wallet/transactions';
|
||||
static String walletExpiring = '/api/v1/customer/wallet/expiring';
|
||||
static String walletPaymentCode = '/api/v1/customer/wallet/payment-code';
|
||||
static String walletExchangePreview =
|
||||
'/api/v1/customer/wallet/exchange/preview';
|
||||
static String walletExchange = '/api/v1/customer/wallet/exchange';
|
||||
@@ -32,6 +27,12 @@ class ApiPath {
|
||||
static String pinOtp = '/api/v1/customer/pin/otp';
|
||||
static String pinReset = '/api/v1/customer/pin/reset';
|
||||
|
||||
// Voucher
|
||||
static String vouchers = '/api/v1/customer/vouchers';
|
||||
static String voucherRedemptions = '/api/v1/customer/vouchers/redemptions';
|
||||
static String voucherRedeem(String voucherId) =>
|
||||
'/api/v1/customer/vouchers/$voucherId/redeem';
|
||||
|
||||
// Device (push notification)
|
||||
static String devices = '/api/v1/customer/devices';
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
part of '../game.dart';
|
||||
|
||||
@freezed
|
||||
abstract class Game with _$Game {
|
||||
const Game._();
|
||||
|
||||
const factory Game({
|
||||
required String id,
|
||||
required String name,
|
||||
required String type,
|
||||
required bool isActive,
|
||||
required Map<String, dynamic> metadata,
|
||||
required List<GamePrize> prizes,
|
||||
required String createdAt,
|
||||
required String updatedAt,
|
||||
}) = _Game;
|
||||
|
||||
factory Game.empty() => const Game(
|
||||
id: '',
|
||||
name: '',
|
||||
type: '',
|
||||
isActive: false,
|
||||
metadata: {},
|
||||
prizes: [],
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
);
|
||||
|
||||
/// Biaya sekali main dalam EnakCoin (`metadata.coin_cost`), default 1.
|
||||
int get coinCost {
|
||||
final value = metadata['coin_cost'];
|
||||
final cost = value is num ? value.toInt() : int.tryParse('$value');
|
||||
return cost == null || cost <= 0 ? 1 : cost;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
part of '../game.dart';
|
||||
|
||||
@freezed
|
||||
abstract class GamePrize with _$GamePrize {
|
||||
const factory GamePrize({
|
||||
required String id,
|
||||
required String gameId,
|
||||
required String name,
|
||||
required Map<String, dynamic> metadata,
|
||||
required String createdAt,
|
||||
required String updatedAt,
|
||||
}) = _GamePrize;
|
||||
|
||||
factory GamePrize.empty() => const GamePrize(
|
||||
id: '',
|
||||
gameId: '',
|
||||
name: '',
|
||||
metadata: {},
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
part of '../game.dart';
|
||||
|
||||
@freezed
|
||||
abstract class SpinResult with _$SpinResult {
|
||||
const factory SpinResult({
|
||||
required String gamePlayId,
|
||||
required int coinsUsed,
|
||||
required String prizeId,
|
||||
required String prizeName,
|
||||
required int coinsRemaining,
|
||||
}) = _SpinResult;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
part of '../game.dart';
|
||||
|
||||
@freezed
|
||||
sealed class GameFailure with _$GameFailure {
|
||||
const factory GameFailure.serverError(ApiFailure failure) = _ServerError;
|
||||
const factory GameFailure.unexpectedError() = _UnexpectedError;
|
||||
const factory GameFailure.dynamicErrorMessage(String erroMessage) =
|
||||
_DynamicErrorMessage;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import '../../common/api/api_failure.dart';
|
||||
|
||||
part 'game.freezed.dart';
|
||||
|
||||
part 'entity/game_entity.dart';
|
||||
part 'entity/game_prize_entity.dart';
|
||||
part 'entity/spin_result_entity.dart';
|
||||
part 'failures/game_failure.dart';
|
||||
part 'repositories/i_game_repository.dart';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
||||
part of '../game.dart';
|
||||
|
||||
abstract class IGameRepository {
|
||||
Future<Either<GameFailure, Game>> ferrisWheel();
|
||||
|
||||
/// Main satu kali; memotong EnakCoin sebesar biaya game. Tanpa PIN.
|
||||
Future<Either<GameFailure, SpinResult>> spin({required String gameId});
|
||||
}
|
||||
@@ -71,18 +71,11 @@ abstract class CustomerOrderItem with _$CustomerOrderItem {
|
||||
|
||||
@freezed
|
||||
abstract class CustomerOrderPayment with _$CustomerOrderPayment {
|
||||
const CustomerOrderPayment._();
|
||||
|
||||
const factory CustomerOrderPayment({
|
||||
required String id,
|
||||
required String methodName,
|
||||
required String methodType,
|
||||
required int amount,
|
||||
required int refundAmount,
|
||||
|
||||
/// Hanya pada pembayaran EnakPoint.
|
||||
int? pointsUsed,
|
||||
}) = _CustomerOrderPayment;
|
||||
|
||||
bool get isPoint => methodType == 'point';
|
||||
}
|
||||
|
||||
@@ -8,9 +8,6 @@ abstract class Outlet with _$Outlet {
|
||||
required String name,
|
||||
String? address,
|
||||
|
||||
/// Kasir di outlet ini menerima pembayaran EnakPoint.
|
||||
required bool acceptsPointPayment,
|
||||
|
||||
/// Belanja di outlet ini memberi EnakPoint.
|
||||
required bool earnsPoints,
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
part of '../wallet.dart';
|
||||
|
||||
@freezed
|
||||
abstract class PaymentCode with _$PaymentCode {
|
||||
const factory PaymentCode({
|
||||
required String code,
|
||||
required String qrPayload,
|
||||
required DateTime expiresAt,
|
||||
}) = _PaymentCode;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
part of '../wallet.dart';
|
||||
|
||||
/// Voucher di katalog yang bisa ditukar dengan EnakPoint sekarang (§9.1).
|
||||
@freezed
|
||||
abstract class Voucher with _$Voucher {
|
||||
const Voucher._();
|
||||
|
||||
const factory Voucher({
|
||||
required String id,
|
||||
required String name,
|
||||
String? description,
|
||||
String? imageUrl,
|
||||
required VoucherType type,
|
||||
|
||||
/// Nilai voucher dalam rupiah.
|
||||
required int faceValue,
|
||||
|
||||
/// EnakPoint yang dipotong saat ditukar.
|
||||
required int pointCost,
|
||||
|
||||
/// Batas tukar per customer; `null` berarti tanpa batas.
|
||||
int? maxPerCustomer,
|
||||
DateTime? validUntil,
|
||||
|
||||
/// Sisa stok; `null` berarti stok tidak dihitung.
|
||||
int? available,
|
||||
}) = _Voucher;
|
||||
|
||||
bool get isOutOfStock => available != null && available! <= 0;
|
||||
}
|
||||
|
||||
/// Voucher hasil tukar milik customer (§9.3).
|
||||
@freezed
|
||||
abstract class VoucherRedemption with _$VoucherRedemption {
|
||||
const factory VoucherRedemption({
|
||||
required String id,
|
||||
required String voucherId,
|
||||
required String voucherName,
|
||||
String? voucherImageUrl,
|
||||
required VoucherType voucherType,
|
||||
required VoucherRedemptionStatus status,
|
||||
required int faceValue,
|
||||
required int pointCost,
|
||||
|
||||
/// `null` bila voucher tidak memakai kode; customer menunjukkan layar
|
||||
/// voucher ke kasir.
|
||||
String? code,
|
||||
DateTime? codeExpiresAt,
|
||||
DateTime? completedAt,
|
||||
DateTime? createdAt,
|
||||
}) = _VoucherRedemption;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class VoucherRedemptionPage with _$VoucherRedemptionPage {
|
||||
const factory VoucherRedemptionPage({
|
||||
required List<VoucherRedemption> items,
|
||||
required int page,
|
||||
required int totalPages,
|
||||
}) = _VoucherRedemptionPage;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class VoucherRedeemResult with _$VoucherRedeemResult {
|
||||
const factory VoucherRedeemResult({
|
||||
required VoucherRedemption redemption,
|
||||
|
||||
/// Saldo EnakPoint setelah ditukar.
|
||||
required int pointBalance,
|
||||
required bool replayed,
|
||||
}) = _VoucherRedeemResult;
|
||||
}
|
||||
@@ -13,29 +13,47 @@ enum WalletCurrency {
|
||||
value == coin.apiValue ? coin : point;
|
||||
}
|
||||
|
||||
enum WalletTransactionType {
|
||||
earn('EARN', 'Dari belanja'),
|
||||
earnReversal('EARN_REVERSAL', 'Dibatalkan (order di-void/refund)'),
|
||||
payment('PAYMENT', 'Bayar pesanan'),
|
||||
paymentRefund('PAYMENT_REFUND', 'Pengembalian pembayaran'),
|
||||
exchangeOut('EXCHANGE_OUT', 'Ditukar ke EnakPoint'),
|
||||
exchangeIn('EXCHANGE_IN', 'Hasil tukar EnakCoin'),
|
||||
transferOut('TRANSFER_OUT', 'Transfer keluar'),
|
||||
transferIn('TRANSFER_IN', 'Transfer masuk'),
|
||||
gameSpend('GAME_SPEND', 'Main game'),
|
||||
expire('EXPIRE', 'Kedaluwarsa'),
|
||||
adjustment('ADJUSTMENT', 'Koreksi'),
|
||||
migration('MIGRATION', 'Saldo awal'),
|
||||
unknown('', 'Transaksi');
|
||||
const _bothCurrencies = [WalletCurrency.point, WalletCurrency.coin];
|
||||
|
||||
const WalletTransactionType(this.apiValue, this.label);
|
||||
enum WalletTransactionType {
|
||||
earn('EARN', 'Dari belanja', _bothCurrencies),
|
||||
earnReversal(
|
||||
'EARN_REVERSAL',
|
||||
'Dibatalkan (order di-void/refund)',
|
||||
_bothCurrencies,
|
||||
),
|
||||
exchangeOut('EXCHANGE_OUT', 'Ditukar ke EnakPoint', [WalletCurrency.coin]),
|
||||
exchangeIn('EXCHANGE_IN', 'Hasil tukar EnakCoin', [WalletCurrency.point]),
|
||||
transferOut('TRANSFER_OUT', 'Transfer keluar', _bothCurrencies),
|
||||
transferIn('TRANSFER_IN', 'Transfer masuk', _bothCurrencies),
|
||||
gameSpend('GAME_SPEND', 'Main game', [WalletCurrency.coin]),
|
||||
gameSpendRefund('GAME_SPEND_REFUND', 'Biaya main dikembalikan', [
|
||||
WalletCurrency.coin,
|
||||
]),
|
||||
gameReward('GAME_REWARD', 'Hadiah game', [WalletCurrency.coin]),
|
||||
rewardRedeem('REWARD_REDEEM', 'Ditukar ke voucher', [WalletCurrency.point]),
|
||||
rewardRedeemRefund('REWARD_REDEEM_REFUND', 'Penukaran voucher dibatalkan', [
|
||||
WalletCurrency.point,
|
||||
]),
|
||||
expire('EXPIRE', 'Kedaluwarsa', _bothCurrencies),
|
||||
adjustment('ADJUSTMENT', 'Koreksi', _bothCurrencies),
|
||||
migration('MIGRATION', 'Saldo awal', _bothCurrencies),
|
||||
|
||||
/// Tipe baru dari backend yang belum dikenal app: tampilkan `description`
|
||||
/// tanpa label.
|
||||
unknown('', '', []);
|
||||
|
||||
const WalletTransactionType(this.apiValue, this.label, this.currencies);
|
||||
|
||||
final String apiValue;
|
||||
final String label;
|
||||
|
||||
/// Tipe yang bisa dipilih di filter riwayat.
|
||||
static List<WalletTransactionType> get filterable =>
|
||||
values.where((e) => e != unknown).toList();
|
||||
/// Mata uang yang bisa memiliki tipe ini.
|
||||
final List<WalletCurrency> currencies;
|
||||
|
||||
/// Tipe yang bisa dipilih di filter riwayat untuk tab [currency].
|
||||
static List<WalletTransactionType> filterableFor(WalletCurrency currency) =>
|
||||
values.where((e) => e.currencies.contains(currency)).toList();
|
||||
|
||||
static WalletTransactionType fromApi(String? value) =>
|
||||
values.firstWhere((e) => e.apiValue == value, orElse: () => unknown);
|
||||
@@ -49,3 +67,36 @@ enum PinOtpPurpose {
|
||||
|
||||
final String apiValue;
|
||||
}
|
||||
|
||||
enum VoucherType {
|
||||
fixedValue('FIXED_VALUE'),
|
||||
percentage('PERCENTAGE'),
|
||||
freeItem('FREE_ITEM'),
|
||||
merchantBenefit('MERCHANT_BENEFIT'),
|
||||
unknown('');
|
||||
|
||||
const VoucherType(this.apiValue);
|
||||
|
||||
final String apiValue;
|
||||
|
||||
static VoucherType fromApi(String? value) =>
|
||||
values.firstWhere((e) => e.apiValue == value, orElse: () => unknown);
|
||||
}
|
||||
|
||||
enum VoucherRedemptionStatus {
|
||||
completed('COMPLETED'),
|
||||
|
||||
/// Sedang diproses penyedia luar; EnakPoint sudah terpotong.
|
||||
pending('PENDING'),
|
||||
|
||||
/// Gagal; EnakPoint dikembalikan otomatis (`REWARD_REDEEM_REFUND`).
|
||||
failed('FAILED'),
|
||||
unknown('');
|
||||
|
||||
const VoucherRedemptionStatus(this.apiValue);
|
||||
|
||||
final String apiValue;
|
||||
|
||||
static VoucherRedemptionStatus fromApi(String? value) =>
|
||||
values.firstWhere((e) => e.apiValue == value, orElse: () => unknown);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
part of '../wallet.dart';
|
||||
|
||||
/// Riwayat order customer. App hanya melihat order; tidak membuat atau
|
||||
/// membayarnya (§7).
|
||||
/// membayarnya; EnakPoint tidak bisa dipakai membayar (§1).
|
||||
abstract class IOrderRepository {
|
||||
Future<Either<WalletFailure, CustomerOrderPage>> getOrders({
|
||||
required int page,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
part of '../wallet.dart';
|
||||
|
||||
/// Tukar EnakPoint ke voucher (§9).
|
||||
abstract class IVoucherRepository {
|
||||
/// Voucher yang aktif, dalam masa berlaku, dan masih ada stoknya.
|
||||
Future<Either<WalletFailure, List<Voucher>>> getVouchers();
|
||||
|
||||
Future<Either<WalletFailure, VoucherRedeemResult>> redeem({
|
||||
required String voucherId,
|
||||
required String pin,
|
||||
required String idempotencyKey,
|
||||
});
|
||||
|
||||
/// Penukaran milik customer, terbaru di atas.
|
||||
Future<Either<WalletFailure, VoucherRedemptionPage>> getRedemptions({
|
||||
required int page,
|
||||
int limit = 20,
|
||||
});
|
||||
}
|
||||
@@ -14,10 +14,6 @@ abstract class IWalletRepository {
|
||||
|
||||
Future<Either<WalletFailure, WalletExpiring>> getExpiring();
|
||||
|
||||
Future<Either<WalletFailure, PaymentCode>> createPaymentCode({
|
||||
required String pin,
|
||||
});
|
||||
|
||||
Future<Either<WalletFailure, ExchangePreview>> previewExchange({
|
||||
required int coins,
|
||||
});
|
||||
|
||||
@@ -9,15 +9,16 @@ part 'entities/wallet_enum.dart';
|
||||
part 'entities/wallet_summary_entity.dart';
|
||||
part 'entities/wallet_transaction_entity.dart';
|
||||
part 'entities/wallet_expiring_entity.dart';
|
||||
part 'entities/payment_code_entity.dart';
|
||||
part 'entities/exchange_entity.dart';
|
||||
part 'entities/transfer_entity.dart';
|
||||
part 'entities/pin_entity.dart';
|
||||
part 'entities/outlet_entity.dart';
|
||||
part 'entities/customer_order_entity.dart';
|
||||
part 'entities/voucher_entity.dart';
|
||||
part 'failures/wallet_failure.dart';
|
||||
part 'repositories/i_wallet_repository.dart';
|
||||
part 'repositories/i_pin_repository.dart';
|
||||
part 'repositories/i_device_repository.dart';
|
||||
part 'repositories/i_outlet_repository.dart';
|
||||
part 'repositories/i_order_repository.dart';
|
||||
part 'repositories/i_voucher_repository.dart';
|
||||
|
||||
+1206
-320
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -9,8 +9,8 @@ abstract class Env {
|
||||
@dev
|
||||
class DevEnv implements Env {
|
||||
@override
|
||||
// String get baseUrl => 'http://192.168.1.5:4000'; // example value
|
||||
String get baseUrl => 'https://enaklo-pos-api.altru.id'; // example value
|
||||
String get baseUrl => 'http://194.233.78.1:4001'; // example value
|
||||
// String get baseUrl => 'https://enaklo-pos-api.altru.id'; // example value
|
||||
}
|
||||
|
||||
@Injectable(as: Env)
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:data_channel/data_channel.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import '../../../common/api/api_client.dart';
|
||||
import '../../../common/api/api_failure.dart';
|
||||
import '../../../common/function/app_function.dart';
|
||||
import '../../../common/url/api_path.dart';
|
||||
import '../../../domain/game/game.dart';
|
||||
import '../game_dtos.dart';
|
||||
|
||||
@injectable
|
||||
class GameRemoteDataProvider {
|
||||
final ApiClient _apiClient;
|
||||
final String _logName = "GameRemoteDataProvider";
|
||||
|
||||
GameRemoteDataProvider(this._apiClient);
|
||||
|
||||
Future<DC<GameFailure, GameDto>> ferrisWheel() async {
|
||||
try {
|
||||
final response = await _apiClient.get(
|
||||
ApiPath.ferrisWheel,
|
||||
headers: getAuthorizationHeader(),
|
||||
);
|
||||
|
||||
if (response.data['code'] == 401) {
|
||||
return DC.error(
|
||||
GameFailure.serverError(ApiFailure.unauthorized('Session Expired')),
|
||||
);
|
||||
}
|
||||
|
||||
if (response.data['status'] == false) {
|
||||
return DC.error(
|
||||
GameFailure.dynamicErrorMessage('Terjadi kesalahan coba lagi nanti'),
|
||||
);
|
||||
}
|
||||
|
||||
final dto = GameDto.fromJson(response.data['data']['data']['game']);
|
||||
|
||||
return DC.data(dto);
|
||||
} on ApiFailure catch (e, s) {
|
||||
log('ferrisWheel', name: _logName, error: e, stackTrace: s);
|
||||
return DC.error(GameFailure.serverError(e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<DC<GameFailure, SpinResultDto>> spin({required String gameId}) async {
|
||||
try {
|
||||
final response = await _apiClient.post(
|
||||
ApiPath.spin,
|
||||
headers: getAuthorizationHeader(),
|
||||
data: {'spin_id': gameId},
|
||||
);
|
||||
|
||||
if (response.data is! Map || response.data['success'] != true) {
|
||||
final errors = response.data is Map ? response.data['errors'] : null;
|
||||
final code = errors is List && errors.isNotEmpty && errors.first is Map
|
||||
? errors.first['code']?.toString()
|
||||
: null;
|
||||
|
||||
return DC.error(
|
||||
GameFailure.dynamicErrorMessage(
|
||||
code == '304'
|
||||
? 'EnakCoin kurang, game sedang tidak aktif, atau hadiah baru '
|
||||
'saja habis. EnakCoin kamu tidak terpotong, silakan coba lagi.'
|
||||
: 'Terjadi kesalahan, coba lagi',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final dto = SpinResultDto.fromJson(
|
||||
Map<String, dynamic>.from(response.data['data'] as Map),
|
||||
);
|
||||
return DC.data(dto);
|
||||
} on ApiFailure catch (e, s) {
|
||||
log('spin', name: _logName, error: e, stackTrace: s);
|
||||
return DC.error(GameFailure.serverError(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
part of '../game_dtos.dart';
|
||||
|
||||
@freezed
|
||||
abstract class GameDto with _$GameDto {
|
||||
const factory GameDto({
|
||||
@JsonKey(name: 'id') String? id,
|
||||
@JsonKey(name: 'name') String? name,
|
||||
@JsonKey(name: 'type') String? type,
|
||||
@JsonKey(name: 'is_active') bool? isActive,
|
||||
@JsonKey(name: 'metadata') Map<String, dynamic>? metadata,
|
||||
@JsonKey(name: 'prizes') List<GamePrizeDto>? prizes,
|
||||
@JsonKey(name: 'created_at') String? createdAt,
|
||||
@JsonKey(name: 'updated_at') String? updatedAt,
|
||||
}) = _GameDto;
|
||||
|
||||
factory GameDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$GameDtoFromJson(json);
|
||||
|
||||
const GameDto._();
|
||||
|
||||
Game toDomain() => Game(
|
||||
id: id ?? '',
|
||||
name: name ?? '',
|
||||
type: type ?? '',
|
||||
isActive: isActive ?? false,
|
||||
metadata: metadata ?? {},
|
||||
prizes: prizes?.map((e) => e.toDomain()).toList() ?? [],
|
||||
createdAt: createdAt ?? '',
|
||||
updatedAt: updatedAt ?? '',
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
part of '../game_dtos.dart';
|
||||
|
||||
@freezed
|
||||
abstract class GamePrizeDto with _$GamePrizeDto {
|
||||
const factory GamePrizeDto({
|
||||
@JsonKey(name: 'id') String? id,
|
||||
@JsonKey(name: 'game_id') String? gameId,
|
||||
@JsonKey(name: 'name') String? name,
|
||||
@JsonKey(name: 'metadata') Map<String, dynamic>? metadata,
|
||||
@JsonKey(name: 'created_at') String? createdAt,
|
||||
@JsonKey(name: 'updated_at') String? updatedAt,
|
||||
}) = _GamePrizeDto;
|
||||
|
||||
factory GamePrizeDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$GamePrizeDtoFromJson(json);
|
||||
|
||||
const GamePrizeDto._();
|
||||
|
||||
GamePrize toDomain() => GamePrize(
|
||||
id: id ?? '',
|
||||
gameId: gameId ?? '',
|
||||
name: name ?? '',
|
||||
metadata: metadata ?? {},
|
||||
createdAt: createdAt ?? '',
|
||||
updatedAt: updatedAt ?? '',
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
part of '../game_dtos.dart';
|
||||
|
||||
@freezed
|
||||
abstract class SpinResultDto with _$SpinResultDto {
|
||||
const factory SpinResultDto({
|
||||
@JsonKey(name: 'game_play') GamePlayDto? gamePlay,
|
||||
@JsonKey(name: 'prize_won') PrizeWonDto? prizeWon,
|
||||
@JsonKey(name: 'coins_remaining') int? coinsRemaining,
|
||||
}) = _SpinResultDto;
|
||||
|
||||
factory SpinResultDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$SpinResultDtoFromJson(json);
|
||||
|
||||
const SpinResultDto._();
|
||||
|
||||
SpinResult toDomain() => SpinResult(
|
||||
gamePlayId: gamePlay?.id ?? '',
|
||||
coinsUsed: gamePlay?.coinsUsed ?? 0,
|
||||
prizeId: prizeWon?.id ?? '',
|
||||
prizeName: prizeWon?.name ?? '',
|
||||
coinsRemaining: coinsRemaining ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class GamePlayDto with _$GamePlayDto {
|
||||
const factory GamePlayDto({
|
||||
@JsonKey(name: 'id') String? id,
|
||||
@JsonKey(name: 'game_id') String? gameId,
|
||||
@JsonKey(name: 'coins_used') int? coinsUsed,
|
||||
@JsonKey(name: 'created_at') String? createdAt,
|
||||
}) = _GamePlayDto;
|
||||
|
||||
factory GamePlayDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$GamePlayDtoFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class PrizeWonDto with _$PrizeWonDto {
|
||||
const factory PrizeWonDto({
|
||||
@JsonKey(name: 'id') String? id,
|
||||
@JsonKey(name: 'name') String? name,
|
||||
}) = _PrizeWonDto;
|
||||
|
||||
factory PrizeWonDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$PrizeWonDtoFromJson(json);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import '../../domain/game/game.dart';
|
||||
|
||||
part 'game_dtos.freezed.dart';
|
||||
part 'game_dtos.g.dart';
|
||||
|
||||
part 'dto/game_dto.dart';
|
||||
part 'dto/game_prize_dto.dart';
|
||||
part 'dto/spin_result_dto.dart';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,90 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'game_dtos.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_GameDto _$GameDtoFromJson(Map<String, dynamic> json) => _GameDto(
|
||||
id: json['id'] as String?,
|
||||
name: json['name'] as String?,
|
||||
type: json['type'] as String?,
|
||||
isActive: json['is_active'] as bool?,
|
||||
metadata: json['metadata'] as Map<String, dynamic>?,
|
||||
prizes: (json['prizes'] as List<dynamic>?)
|
||||
?.map((e) => GamePrizeDto.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
createdAt: json['created_at'] as String?,
|
||||
updatedAt: json['updated_at'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$GameDtoToJson(_GameDto instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'type': instance.type,
|
||||
'is_active': instance.isActive,
|
||||
'metadata': instance.metadata,
|
||||
'prizes': instance.prizes,
|
||||
'created_at': instance.createdAt,
|
||||
'updated_at': instance.updatedAt,
|
||||
};
|
||||
|
||||
_GamePrizeDto _$GamePrizeDtoFromJson(Map<String, dynamic> json) =>
|
||||
_GamePrizeDto(
|
||||
id: json['id'] as String?,
|
||||
gameId: json['game_id'] as String?,
|
||||
name: json['name'] as String?,
|
||||
metadata: json['metadata'] as Map<String, dynamic>?,
|
||||
createdAt: json['created_at'] as String?,
|
||||
updatedAt: json['updated_at'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$GamePrizeDtoToJson(_GamePrizeDto instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'game_id': instance.gameId,
|
||||
'name': instance.name,
|
||||
'metadata': instance.metadata,
|
||||
'created_at': instance.createdAt,
|
||||
'updated_at': instance.updatedAt,
|
||||
};
|
||||
|
||||
_SpinResultDto _$SpinResultDtoFromJson(Map<String, dynamic> json) =>
|
||||
_SpinResultDto(
|
||||
gamePlay: json['game_play'] == null
|
||||
? null
|
||||
: GamePlayDto.fromJson(json['game_play'] as Map<String, dynamic>),
|
||||
prizeWon: json['prize_won'] == null
|
||||
? null
|
||||
: PrizeWonDto.fromJson(json['prize_won'] as Map<String, dynamic>),
|
||||
coinsRemaining: (json['coins_remaining'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SpinResultDtoToJson(_SpinResultDto instance) =>
|
||||
<String, dynamic>{
|
||||
'game_play': instance.gamePlay,
|
||||
'prize_won': instance.prizeWon,
|
||||
'coins_remaining': instance.coinsRemaining,
|
||||
};
|
||||
|
||||
_GamePlayDto _$GamePlayDtoFromJson(Map<String, dynamic> json) => _GamePlayDto(
|
||||
id: json['id'] as String?,
|
||||
gameId: json['game_id'] as String?,
|
||||
coinsUsed: (json['coins_used'] as num?)?.toInt(),
|
||||
createdAt: json['created_at'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$GamePlayDtoToJson(_GamePlayDto instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'game_id': instance.gameId,
|
||||
'coins_used': instance.coinsUsed,
|
||||
'created_at': instance.createdAt,
|
||||
};
|
||||
|
||||
_PrizeWonDto _$PrizeWonDtoFromJson(Map<String, dynamic> json) =>
|
||||
_PrizeWonDto(id: json['id'] as String?, name: json['name'] as String?);
|
||||
|
||||
Map<String, dynamic> _$PrizeWonDtoToJson(_PrizeWonDto instance) =>
|
||||
<String, dynamic>{'id': instance.id, 'name': instance.name};
|
||||
@@ -1,50 +0,0 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import '../../../domain/game/game.dart';
|
||||
import '../datasources/remote_data_provider.dart';
|
||||
|
||||
@Injectable(as: IGameRepository)
|
||||
class GameRepository implements IGameRepository {
|
||||
final GameRemoteDataProvider _remoteDataProvider;
|
||||
|
||||
final String _logName = 'GameRepository';
|
||||
|
||||
GameRepository(this._remoteDataProvider);
|
||||
|
||||
@override
|
||||
Future<Either<GameFailure, Game>> ferrisWheel() async {
|
||||
try {
|
||||
final result = await _remoteDataProvider.ferrisWheel();
|
||||
|
||||
if (result.hasError) {
|
||||
return left(result.error!);
|
||||
}
|
||||
|
||||
final data = result.data!.toDomain();
|
||||
|
||||
return right(data);
|
||||
} catch (e, s) {
|
||||
log('ferrisWheel', name: _logName, error: e, stackTrace: s);
|
||||
return left(const GameFailure.unexpectedError());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<GameFailure, SpinResult>> spin({required String gameId}) async {
|
||||
try {
|
||||
final result = await _remoteDataProvider.spin(gameId: gameId);
|
||||
|
||||
if (result.hasError) {
|
||||
return left(result.error!);
|
||||
}
|
||||
|
||||
return right(result.data!.toDomain());
|
||||
} catch (e, s) {
|
||||
log('spin', name: _logName, error: e, stackTrace: s);
|
||||
return left(const GameFailure.unexpectedError());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:data_channel/data_channel.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import '../../../common/api/api_client.dart';
|
||||
import '../../../common/api/api_failure.dart';
|
||||
import '../../../common/api/interceptors/sensitive_aware_log_interceptor.dart';
|
||||
import '../../../common/function/app_function.dart';
|
||||
import '../../../common/url/api_path.dart';
|
||||
import '../../../domain/wallet/wallet.dart';
|
||||
import '../wallet_dtos.dart';
|
||||
import '../wallet_response.dart';
|
||||
|
||||
/// Katalog, tukar, dan riwayat voucher (§9).
|
||||
@injectable
|
||||
class VoucherRemoteDataProvider {
|
||||
final ApiClient _apiClient;
|
||||
final String _logName = 'VoucherRemoteDataProvider';
|
||||
|
||||
VoucherRemoteDataProvider(this._apiClient);
|
||||
|
||||
Future<DC<WalletFailure, List<VoucherDto>>> fetchVouchers() => _request(
|
||||
'fetchVouchers',
|
||||
() => _apiClient.get(ApiPath.vouchers, headers: getAuthorizationHeader()),
|
||||
(response) => ((response.data as Map)['data'] as List? ?? [])
|
||||
.map((e) => VoucherDto.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Future<DC<WalletFailure, VoucherRedemptionDto>> redeem({
|
||||
required String voucherId,
|
||||
required String pin,
|
||||
required String idempotencyKey,
|
||||
}) => _request(
|
||||
'redeem',
|
||||
() => _apiClient.post(
|
||||
ApiPath.voucherRedeem(voucherId),
|
||||
headers: {...getAuthorizationHeader(), 'Idempotency-Key': idempotencyKey},
|
||||
data: {'pin': pin},
|
||||
extra: {kSensitiveRequest: true},
|
||||
),
|
||||
(response) => VoucherRedemptionDto.fromJson(walletDataOf(response)),
|
||||
);
|
||||
|
||||
Future<DC<WalletFailure, VoucherRedemptionPageDto>> fetchRedemptions({
|
||||
required int page,
|
||||
required int limit,
|
||||
}) => _request(
|
||||
'fetchRedemptions',
|
||||
() => _apiClient.get(
|
||||
ApiPath.voucherRedemptions,
|
||||
headers: getAuthorizationHeader(),
|
||||
params: {'page': page, 'limit': limit},
|
||||
),
|
||||
_parseRedemptionPage,
|
||||
);
|
||||
|
||||
/// `data` bisa berupa list langsung dengan `pagination` di envelope, atau
|
||||
/// objek `{ data, pagination }`.
|
||||
VoucherRedemptionPageDto _parseRedemptionPage(Response response) {
|
||||
final body = response.data as Map;
|
||||
final payload = body['data'];
|
||||
final rawItems = payload is Map ? payload['data'] : payload;
|
||||
final rawPagination = payload is Map
|
||||
? payload['pagination']
|
||||
: body['pagination'];
|
||||
|
||||
return VoucherRedemptionPageDto(
|
||||
items: (rawItems as List? ?? [])
|
||||
.map(
|
||||
(e) => VoucherRedemptionDto.fromJson(
|
||||
Map<String, dynamic>.from(e as Map),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
pagination: rawPagination is Map
|
||||
? WalletPaginationDto.fromJson(
|
||||
Map<String, dynamic>.from(rawPagination),
|
||||
)
|
||||
: const WalletPaginationDto(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<DC<WalletFailure, T>> _request<T>(
|
||||
String name,
|
||||
Future<Response> Function() call,
|
||||
T Function(Response response) parse,
|
||||
) async {
|
||||
try {
|
||||
final response = await call();
|
||||
final failure = parseWalletFailure(response);
|
||||
if (failure != null) return DC.error(failure);
|
||||
return DC.data(parse(response));
|
||||
} on ApiFailure catch (e, s) {
|
||||
log(name, name: _logName, error: e, stackTrace: s);
|
||||
return DC.error(WalletFailure.serverError(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,19 +60,6 @@ class WalletRemoteDataProvider {
|
||||
(response) => WalletExpiringDto.fromJson(walletDataOf(response)),
|
||||
);
|
||||
|
||||
Future<DC<WalletFailure, PaymentCodeDto>> createPaymentCode({
|
||||
required String pin,
|
||||
}) => _request(
|
||||
'createPaymentCode',
|
||||
() => _apiClient.post(
|
||||
ApiPath.walletPaymentCode,
|
||||
headers: getAuthorizationHeader(),
|
||||
data: {'pin': pin},
|
||||
extra: {kSensitiveRequest: true},
|
||||
),
|
||||
(response) => PaymentCodeDto.fromJson(walletDataOf(response)),
|
||||
);
|
||||
|
||||
Future<DC<WalletFailure, ExchangePreviewDto>> previewExchange({
|
||||
required int coins,
|
||||
}) => _request(
|
||||
|
||||
@@ -100,7 +100,6 @@ abstract class CustomerOrderPaymentDto with _$CustomerOrderPaymentDto {
|
||||
@JsonKey(name: 'method_type') String? methodType,
|
||||
@JsonKey(name: 'amount') int? amount,
|
||||
@JsonKey(name: 'refund_amount') int? refundAmount,
|
||||
@JsonKey(name: 'points_used') int? pointsUsed,
|
||||
}) = _CustomerOrderPaymentDto;
|
||||
|
||||
factory CustomerOrderPaymentDto.fromJson(Map<String, dynamic> json) =>
|
||||
@@ -114,7 +113,6 @@ abstract class CustomerOrderPaymentDto with _$CustomerOrderPaymentDto {
|
||||
methodType: methodType ?? '',
|
||||
amount: amount ?? 0,
|
||||
refundAmount: refundAmount ?? 0,
|
||||
pointsUsed: pointsUsed,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ abstract class OutletDto with _$OutletDto {
|
||||
@JsonKey(name: 'id') String? id,
|
||||
@JsonKey(name: 'name') String? name,
|
||||
@JsonKey(name: 'address') String? address,
|
||||
@JsonKey(name: 'accepts_point_payment') bool? acceptsPointPayment,
|
||||
@JsonKey(name: 'earns_points') bool? earnsPoints,
|
||||
@JsonKey(name: 'earns_coins') bool? earnsCoins,
|
||||
}) = _OutletDto;
|
||||
@@ -20,7 +19,6 @@ abstract class OutletDto with _$OutletDto {
|
||||
id: id ?? '',
|
||||
name: name ?? '',
|
||||
address: (address?.trim().isEmpty ?? true) ? null : address!.trim(),
|
||||
acceptsPointPayment: acceptsPointPayment ?? false,
|
||||
earnsPoints: earnsPoints ?? false,
|
||||
earnsCoins: earnsCoins ?? false,
|
||||
);
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
part of '../wallet_dtos.dart';
|
||||
|
||||
@freezed
|
||||
abstract class PaymentCodeDto with _$PaymentCodeDto {
|
||||
const factory PaymentCodeDto({
|
||||
@JsonKey(name: 'code') String? code,
|
||||
@JsonKey(name: 'qr_payload') String? qrPayload,
|
||||
@JsonKey(name: 'expires_at') String? expiresAt,
|
||||
}) = _PaymentCodeDto;
|
||||
|
||||
factory PaymentCodeDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$PaymentCodeDtoFromJson(json);
|
||||
|
||||
const PaymentCodeDto._();
|
||||
|
||||
PaymentCode toDomain() => PaymentCode(
|
||||
code: code ?? '',
|
||||
qrPayload: qrPayload ?? '',
|
||||
// Kode berlaku 2 menit; dipakai hanya bila server tidak mengirim expires_at.
|
||||
expiresAt:
|
||||
_parseDateTime(expiresAt) ??
|
||||
DateTime.now().add(const Duration(minutes: 2)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
part of '../wallet_dtos.dart';
|
||||
|
||||
/// `terms` belum dibaca: bentuknya masih disepakati dengan tim backoffice,
|
||||
/// sementara itu yang ditampilkan `description` saja (§9.1).
|
||||
@freezed
|
||||
abstract class VoucherDto with _$VoucherDto {
|
||||
const factory VoucherDto({
|
||||
@JsonKey(name: 'id') String? id,
|
||||
@JsonKey(name: 'name') String? name,
|
||||
@JsonKey(name: 'description') String? description,
|
||||
@JsonKey(name: 'image_url') String? imageUrl,
|
||||
@JsonKey(name: 'voucher_type') String? voucherType,
|
||||
@JsonKey(name: 'face_value') int? faceValue,
|
||||
@JsonKey(name: 'point_cost') int? pointCost,
|
||||
@JsonKey(name: 'max_per_customer') int? maxPerCustomer,
|
||||
@JsonKey(name: 'valid_until') String? validUntil,
|
||||
@JsonKey(name: 'available') int? available,
|
||||
}) = _VoucherDto;
|
||||
|
||||
factory VoucherDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$VoucherDtoFromJson(json);
|
||||
|
||||
const VoucherDto._();
|
||||
|
||||
Voucher toDomain() => Voucher(
|
||||
id: id ?? '',
|
||||
name: name ?? '',
|
||||
description: (description?.trim().isEmpty ?? true) ? null : description,
|
||||
imageUrl: (imageUrl?.isEmpty ?? true) ? null : imageUrl,
|
||||
type: VoucherType.fromApi(voucherType),
|
||||
faceValue: faceValue ?? 0,
|
||||
pointCost: pointCost ?? 0,
|
||||
maxPerCustomer: maxPerCustomer,
|
||||
validUntil: _parseDateTime(validUntil),
|
||||
available: available,
|
||||
);
|
||||
}
|
||||
|
||||
/// Item "Voucher saya" dan response tukar voucher; [pointBalance] dan
|
||||
/// [replayed] hanya ada di response tukar.
|
||||
@freezed
|
||||
abstract class VoucherRedemptionDto with _$VoucherRedemptionDto {
|
||||
const factory VoucherRedemptionDto({
|
||||
@JsonKey(name: 'id') String? id,
|
||||
@JsonKey(name: 'voucher_id') String? voucherId,
|
||||
@JsonKey(name: 'voucher_name') String? voucherName,
|
||||
@JsonKey(name: 'voucher_image_url') String? voucherImageUrl,
|
||||
@JsonKey(name: 'voucher_type') String? voucherType,
|
||||
@JsonKey(name: 'status') String? status,
|
||||
@JsonKey(name: 'face_value') int? faceValue,
|
||||
@JsonKey(name: 'point_cost') int? pointCost,
|
||||
@JsonKey(name: 'code') String? code,
|
||||
@JsonKey(name: 'code_expires_at') String? codeExpiresAt,
|
||||
@JsonKey(name: 'completed_at') String? completedAt,
|
||||
@JsonKey(name: 'created_at') String? createdAt,
|
||||
@JsonKey(name: 'point_balance') int? pointBalance,
|
||||
@JsonKey(name: 'replayed') bool? replayed,
|
||||
}) = _VoucherRedemptionDto;
|
||||
|
||||
factory VoucherRedemptionDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$VoucherRedemptionDtoFromJson(json);
|
||||
|
||||
const VoucherRedemptionDto._();
|
||||
|
||||
VoucherRedemption toDomain() => VoucherRedemption(
|
||||
id: id ?? '',
|
||||
voucherId: voucherId ?? '',
|
||||
voucherName: voucherName ?? '',
|
||||
voucherImageUrl: (voucherImageUrl?.isEmpty ?? true)
|
||||
? null
|
||||
: voucherImageUrl,
|
||||
voucherType: VoucherType.fromApi(voucherType),
|
||||
status: VoucherRedemptionStatus.fromApi(status),
|
||||
faceValue: faceValue ?? 0,
|
||||
pointCost: pointCost ?? 0,
|
||||
code: (code?.isEmpty ?? true) ? null : code,
|
||||
codeExpiresAt: _parseDateTime(codeExpiresAt),
|
||||
completedAt: _parseDateTime(completedAt),
|
||||
createdAt: _parseDateTime(createdAt),
|
||||
);
|
||||
|
||||
VoucherRedeemResult toRedeemResult() => VoucherRedeemResult(
|
||||
redemption: toDomain(),
|
||||
pointBalance: pointBalance ?? 0,
|
||||
replayed: replayed ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
class VoucherRedemptionPageDto {
|
||||
final List<VoucherRedemptionDto> items;
|
||||
final WalletPaginationDto pagination;
|
||||
|
||||
const VoucherRedemptionPageDto({
|
||||
required this.items,
|
||||
required this.pagination,
|
||||
});
|
||||
|
||||
VoucherRedemptionPage toDomain() => VoucherRedemptionPage(
|
||||
items: items.map((e) => e.toDomain()).toList(),
|
||||
page: pagination.page ?? 1,
|
||||
totalPages: pagination.totalPages ?? 1,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:data_channel/data_channel.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import '../../../domain/wallet/wallet.dart';
|
||||
import '../datasources/voucher_remote_data_provider.dart';
|
||||
|
||||
@Injectable(as: IVoucherRepository)
|
||||
class VoucherRepository implements IVoucherRepository {
|
||||
final VoucherRemoteDataProvider _remoteDataProvider;
|
||||
|
||||
final String _logName = 'VoucherRepository';
|
||||
|
||||
VoucherRepository(this._remoteDataProvider);
|
||||
|
||||
@override
|
||||
Future<Either<WalletFailure, List<Voucher>>> getVouchers() => _run(
|
||||
'getVouchers',
|
||||
_remoteDataProvider.fetchVouchers,
|
||||
(dtos) => dtos.map((e) => e.toDomain()).toList(),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<Either<WalletFailure, VoucherRedeemResult>> redeem({
|
||||
required String voucherId,
|
||||
required String pin,
|
||||
required String idempotencyKey,
|
||||
}) => _run(
|
||||
'redeem',
|
||||
() => _remoteDataProvider.redeem(
|
||||
voucherId: voucherId,
|
||||
pin: pin,
|
||||
idempotencyKey: idempotencyKey,
|
||||
),
|
||||
(dto) => dto.toRedeemResult(),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<Either<WalletFailure, VoucherRedemptionPage>> getRedemptions({
|
||||
required int page,
|
||||
int limit = 20,
|
||||
}) => _run(
|
||||
'getRedemptions',
|
||||
() => _remoteDataProvider.fetchRedemptions(page: page, limit: limit),
|
||||
(dto) => dto.toDomain(),
|
||||
);
|
||||
|
||||
Future<Either<WalletFailure, R>> _run<D, R>(
|
||||
String name,
|
||||
Future<DC<WalletFailure, D>> Function() call,
|
||||
R Function(D dto) toDomain,
|
||||
) async {
|
||||
try {
|
||||
final result = await call();
|
||||
if (result.hasError) return left(result.error!);
|
||||
return right(toDomain(result.data as D));
|
||||
} catch (e, s) {
|
||||
log(name, name: _logName, error: e, stackTrace: s);
|
||||
return left(const WalletFailure.unexpectedError());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,15 +50,6 @@ class WalletRepository implements IWalletRepository {
|
||||
(dto) => dto.toDomain(),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<Either<WalletFailure, PaymentCode>> createPaymentCode({
|
||||
required String pin,
|
||||
}) => _run(
|
||||
'createPaymentCode',
|
||||
() => _remoteDataProvider.createPaymentCode(pin: pin),
|
||||
(dto) => dto.toDomain(),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<Either<WalletFailure, ExchangePreview>> previewExchange({
|
||||
required int coins,
|
||||
|
||||
@@ -8,12 +8,12 @@ part 'wallet_dtos.g.dart';
|
||||
part 'dto/wallet_summary_dto.dart';
|
||||
part 'dto/wallet_transaction_dto.dart';
|
||||
part 'dto/wallet_expiring_dto.dart';
|
||||
part 'dto/payment_code_dto.dart';
|
||||
part 'dto/exchange_dto.dart';
|
||||
part 'dto/transfer_dto.dart';
|
||||
part 'dto/pin_dto.dart';
|
||||
part 'dto/outlet_dto.dart';
|
||||
part 'dto/customer_order_dto.dart';
|
||||
part 'dto/voucher_dto.dart';
|
||||
|
||||
DateTime? _parseDateTime(String? value) =>
|
||||
value == null ? null : DateTime.tryParse(value);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -112,20 +112,6 @@ _WalletExpiringDto _$WalletExpiringDtoFromJson(Map<String, dynamic> json) =>
|
||||
Map<String, dynamic> _$WalletExpiringDtoToJson(_WalletExpiringDto instance) =>
|
||||
<String, dynamic>{'point': instance.point, 'coin': instance.coin};
|
||||
|
||||
_PaymentCodeDto _$PaymentCodeDtoFromJson(Map<String, dynamic> json) =>
|
||||
_PaymentCodeDto(
|
||||
code: json['code'] as String?,
|
||||
qrPayload: json['qr_payload'] as String?,
|
||||
expiresAt: json['expires_at'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PaymentCodeDtoToJson(_PaymentCodeDto instance) =>
|
||||
<String, dynamic>{
|
||||
'code': instance.code,
|
||||
'qr_payload': instance.qrPayload,
|
||||
'expires_at': instance.expiresAt,
|
||||
};
|
||||
|
||||
_ExchangePreviewDto _$ExchangePreviewDtoFromJson(Map<String, dynamic> json) =>
|
||||
_ExchangePreviewDto(
|
||||
coinAmount: (json['coin_amount'] as num?)?.toInt(),
|
||||
@@ -253,7 +239,6 @@ _OutletDto _$OutletDtoFromJson(Map<String, dynamic> json) => _OutletDto(
|
||||
id: json['id'] as String?,
|
||||
name: json['name'] as String?,
|
||||
address: json['address'] as String?,
|
||||
acceptsPointPayment: json['accepts_point_payment'] as bool?,
|
||||
earnsPoints: json['earns_points'] as bool?,
|
||||
earnsCoins: json['earns_coins'] as bool?,
|
||||
);
|
||||
@@ -263,7 +248,6 @@ Map<String, dynamic> _$OutletDtoToJson(_OutletDto instance) =>
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'address': instance.address,
|
||||
'accepts_point_payment': instance.acceptsPointPayment,
|
||||
'earns_points': instance.earnsPoints,
|
||||
'earns_coins': instance.earnsCoins,
|
||||
};
|
||||
@@ -358,7 +342,6 @@ _CustomerOrderPaymentDto _$CustomerOrderPaymentDtoFromJson(
|
||||
methodType: json['method_type'] as String?,
|
||||
amount: (json['amount'] as num?)?.toInt(),
|
||||
refundAmount: (json['refund_amount'] as num?)?.toInt(),
|
||||
pointsUsed: (json['points_used'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CustomerOrderPaymentDtoToJson(
|
||||
@@ -369,5 +352,69 @@ Map<String, dynamic> _$CustomerOrderPaymentDtoToJson(
|
||||
'method_type': instance.methodType,
|
||||
'amount': instance.amount,
|
||||
'refund_amount': instance.refundAmount,
|
||||
'points_used': instance.pointsUsed,
|
||||
};
|
||||
|
||||
_VoucherDto _$VoucherDtoFromJson(Map<String, dynamic> json) => _VoucherDto(
|
||||
id: json['id'] as String?,
|
||||
name: json['name'] as String?,
|
||||
description: json['description'] as String?,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
voucherType: json['voucher_type'] as String?,
|
||||
faceValue: (json['face_value'] as num?)?.toInt(),
|
||||
pointCost: (json['point_cost'] as num?)?.toInt(),
|
||||
maxPerCustomer: (json['max_per_customer'] as num?)?.toInt(),
|
||||
validUntil: json['valid_until'] as String?,
|
||||
available: (json['available'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$VoucherDtoToJson(_VoucherDto instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'description': instance.description,
|
||||
'image_url': instance.imageUrl,
|
||||
'voucher_type': instance.voucherType,
|
||||
'face_value': instance.faceValue,
|
||||
'point_cost': instance.pointCost,
|
||||
'max_per_customer': instance.maxPerCustomer,
|
||||
'valid_until': instance.validUntil,
|
||||
'available': instance.available,
|
||||
};
|
||||
|
||||
_VoucherRedemptionDto _$VoucherRedemptionDtoFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _VoucherRedemptionDto(
|
||||
id: json['id'] as String?,
|
||||
voucherId: json['voucher_id'] as String?,
|
||||
voucherName: json['voucher_name'] as String?,
|
||||
voucherImageUrl: json['voucher_image_url'] as String?,
|
||||
voucherType: json['voucher_type'] as String?,
|
||||
status: json['status'] as String?,
|
||||
faceValue: (json['face_value'] as num?)?.toInt(),
|
||||
pointCost: (json['point_cost'] as num?)?.toInt(),
|
||||
code: json['code'] as String?,
|
||||
codeExpiresAt: json['code_expires_at'] as String?,
|
||||
completedAt: json['completed_at'] as String?,
|
||||
createdAt: json['created_at'] as String?,
|
||||
pointBalance: (json['point_balance'] as num?)?.toInt(),
|
||||
replayed: json['replayed'] as bool?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$VoucherRedemptionDtoToJson(
|
||||
_VoucherRedemptionDto instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'voucher_id': instance.voucherId,
|
||||
'voucher_name': instance.voucherName,
|
||||
'voucher_image_url': instance.voucherImageUrl,
|
||||
'voucher_type': instance.voucherType,
|
||||
'status': instance.status,
|
||||
'face_value': instance.faceValue,
|
||||
'point_cost': instance.pointCost,
|
||||
'code': instance.code,
|
||||
'code_expires_at': instance.codeExpiresAt,
|
||||
'completed_at': instance.completedAt,
|
||||
'created_at': instance.createdAt,
|
||||
'point_balance': instance.pointBalance,
|
||||
'replayed': instance.replayed,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../common/api/interceptors/unauthorized_interceptor.dart';
|
||||
import '../../domain/wallet/wallet.dart';
|
||||
|
||||
/// Hitung mundur bawaan bila server menjawab `429` tanpa header `Retry-After`.
|
||||
@@ -8,7 +9,7 @@ const int kDefaultOtpRetrySeconds = 60;
|
||||
/// Membaca envelope `{ success, data, errors }`. Mengembalikan `null` bila
|
||||
/// sukses, atau [WalletFailure] yang sesuai bila gagal.
|
||||
WalletFailure? parseWalletFailure(Response response) {
|
||||
if (response.statusCode == 401) return const WalletFailure.unauthorized();
|
||||
if (isTokenRejected(response)) return const WalletFailure.unauthorized();
|
||||
|
||||
final body = response.data;
|
||||
if (body is! Map) return _failureFromStatus(response);
|
||||
|
||||
+25
-25
@@ -27,9 +27,6 @@ import 'package:enaklo/application/auth/set_password/set_password_form_bloc.dart
|
||||
as _i175;
|
||||
import 'package:enaklo/application/auth/verify_form/verify_form_bloc.dart'
|
||||
as _i521;
|
||||
import 'package:enaklo/application/game/ferris_wheel_loader/ferris_wheel_loader_bloc.dart'
|
||||
as _i1013;
|
||||
import 'package:enaklo/application/game/game_spin/game_spin_bloc.dart' as _i744;
|
||||
import 'package:enaklo/application/order/order_detail_loader/order_detail_loader_bloc.dart'
|
||||
as _i342;
|
||||
import 'package:enaklo/application/order/order_loader/order_loader_bloc.dart'
|
||||
@@ -40,10 +37,14 @@ import 'package:enaklo/application/pin/pin_change_form/pin_change_form_bloc.dart
|
||||
as _i538;
|
||||
import 'package:enaklo/application/pin/pin_setup_form/pin_setup_form_bloc.dart'
|
||||
as _i390;
|
||||
import 'package:enaklo/application/voucher/voucher_loader/voucher_loader_bloc.dart'
|
||||
as _i137;
|
||||
import 'package:enaklo/application/voucher/voucher_redeem_form/voucher_redeem_form_bloc.dart'
|
||||
as _i304;
|
||||
import 'package:enaklo/application/voucher/voucher_redemption_loader/voucher_redemption_loader_bloc.dart'
|
||||
as _i1045;
|
||||
import 'package:enaklo/application/wallet/exchange_form/exchange_form_bloc.dart'
|
||||
as _i691;
|
||||
import 'package:enaklo/application/wallet/payment_code_form/payment_code_form_bloc.dart'
|
||||
as _i847;
|
||||
import 'package:enaklo/application/wallet/transfer_form/transfer_form_bloc.dart'
|
||||
as _i277;
|
||||
import 'package:enaklo/application/wallet/wallet_expiring_loader/wallet_expiring_loader_bloc.dart'
|
||||
@@ -60,7 +61,6 @@ import 'package:enaklo/common/di/di_secure_storage.dart' as _i525;
|
||||
import 'package:enaklo/common/di/di_shared_preferences.dart' as _i672;
|
||||
import 'package:enaklo/common/network/network_client.dart' as _i109;
|
||||
import 'package:enaklo/domain/auth/auth.dart' as _i995;
|
||||
import 'package:enaklo/domain/game/game.dart' as _i96;
|
||||
import 'package:enaklo/domain/wallet/wallet.dart' as _i66;
|
||||
import 'package:enaklo/env.dart' as _i372;
|
||||
import 'package:enaklo/infrastructure/auth/datasources/local_data_provider.dart'
|
||||
@@ -69,16 +69,14 @@ import 'package:enaklo/infrastructure/auth/datasources/remote_data_provider.dart
|
||||
as _i818;
|
||||
import 'package:enaklo/infrastructure/auth/repositories/auth_repository.dart'
|
||||
as _i879;
|
||||
import 'package:enaklo/infrastructure/game/datasources/remote_data_provider.dart'
|
||||
as _i143;
|
||||
import 'package:enaklo/infrastructure/game/repositories/game_repository.dart'
|
||||
as _i547;
|
||||
import 'package:enaklo/infrastructure/wallet/datasources/customer_remote_data_provider.dart'
|
||||
as _i852;
|
||||
import 'package:enaklo/infrastructure/wallet/datasources/device_data_provider.dart'
|
||||
as _i489;
|
||||
import 'package:enaklo/infrastructure/wallet/datasources/pin_remote_data_provider.dart'
|
||||
as _i507;
|
||||
import 'package:enaklo/infrastructure/wallet/datasources/voucher_remote_data_provider.dart'
|
||||
as _i159;
|
||||
import 'package:enaklo/infrastructure/wallet/datasources/wallet_remote_data_provider.dart'
|
||||
as _i152;
|
||||
import 'package:enaklo/infrastructure/wallet/repositories/device_repository.dart'
|
||||
@@ -89,6 +87,8 @@ import 'package:enaklo/infrastructure/wallet/repositories/outlet_repository.dart
|
||||
as _i369;
|
||||
import 'package:enaklo/infrastructure/wallet/repositories/pin_repository.dart'
|
||||
as _i846;
|
||||
import 'package:enaklo/infrastructure/wallet/repositories/voucher_repository.dart'
|
||||
as _i651;
|
||||
import 'package:enaklo/infrastructure/wallet/repositories/wallet_repository.dart'
|
||||
as _i189;
|
||||
import 'package:enaklo/presentation/router/app_router.dart' as _i698;
|
||||
@@ -139,9 +139,6 @@ extension GetItInjectableX on _i174.GetIt {
|
||||
gh.factory<_i818.AuthRemoteDataProvider>(
|
||||
() => _i818.AuthRemoteDataProvider(gh<_i842.ApiClient>()),
|
||||
);
|
||||
gh.factory<_i143.GameRemoteDataProvider>(
|
||||
() => _i143.GameRemoteDataProvider(gh<_i842.ApiClient>()),
|
||||
);
|
||||
gh.factory<_i852.CustomerRemoteDataProvider>(
|
||||
() => _i852.CustomerRemoteDataProvider(gh<_i842.ApiClient>()),
|
||||
);
|
||||
@@ -151,6 +148,9 @@ extension GetItInjectableX on _i174.GetIt {
|
||||
gh.factory<_i507.PinRemoteDataProvider>(
|
||||
() => _i507.PinRemoteDataProvider(gh<_i842.ApiClient>()),
|
||||
);
|
||||
gh.factory<_i159.VoucherRemoteDataProvider>(
|
||||
() => _i159.VoucherRemoteDataProvider(gh<_i842.ApiClient>()),
|
||||
);
|
||||
gh.factory<_i152.WalletRemoteDataProvider>(
|
||||
() => _i152.WalletRemoteDataProvider(gh<_i842.ApiClient>()),
|
||||
);
|
||||
@@ -169,9 +169,6 @@ extension GetItInjectableX on _i174.GetIt {
|
||||
gh.factory<_i691.ExchangeFormBloc>(
|
||||
() => _i691.ExchangeFormBloc(gh<_i66.IWalletRepository>()),
|
||||
);
|
||||
gh.factory<_i847.PaymentCodeFormBloc>(
|
||||
() => _i847.PaymentCodeFormBloc(gh<_i66.IWalletRepository>()),
|
||||
);
|
||||
gh.factory<_i277.TransferFormBloc>(
|
||||
() => _i277.TransferFormBloc(gh<_i66.IWalletRepository>()),
|
||||
);
|
||||
@@ -187,6 +184,18 @@ extension GetItInjectableX on _i174.GetIt {
|
||||
gh.factory<_i66.IOrderRepository>(
|
||||
() => _i453.OrderRepository(gh<_i852.CustomerRemoteDataProvider>()),
|
||||
);
|
||||
gh.factory<_i66.IVoucherRepository>(
|
||||
() => _i651.VoucherRepository(gh<_i159.VoucherRemoteDataProvider>()),
|
||||
);
|
||||
gh.factory<_i137.VoucherLoaderBloc>(
|
||||
() => _i137.VoucherLoaderBloc(gh<_i66.IVoucherRepository>()),
|
||||
);
|
||||
gh.factory<_i304.VoucherRedeemFormBloc>(
|
||||
() => _i304.VoucherRedeemFormBloc(gh<_i66.IVoucherRepository>()),
|
||||
);
|
||||
gh.factory<_i1045.VoucherRedemptionLoaderBloc>(
|
||||
() => _i1045.VoucherRedemptionLoaderBloc(gh<_i66.IVoucherRepository>()),
|
||||
);
|
||||
gh.factory<_i995.IAuthRepository>(
|
||||
() => _i879.AuthRepository(
|
||||
gh<_i818.AuthRemoteDataProvider>(),
|
||||
@@ -202,9 +211,6 @@ extension GetItInjectableX on _i174.GetIt {
|
||||
gh.factory<_i510.LoginFormBloc>(
|
||||
() => _i510.LoginFormBloc(gh<_i995.IAuthRepository>()),
|
||||
);
|
||||
gh.factory<_i96.IGameRepository>(
|
||||
() => _i547.GameRepository(gh<_i143.GameRemoteDataProvider>()),
|
||||
);
|
||||
gh.factory<_i771.AuthBloc>(
|
||||
() => _i771.AuthBloc(gh<_i995.IAuthRepository>()),
|
||||
);
|
||||
@@ -241,12 +247,6 @@ extension GetItInjectableX on _i174.GetIt {
|
||||
gh.factory<_i554.OrderLoaderBloc>(
|
||||
() => _i554.OrderLoaderBloc(gh<_i66.IOrderRepository>()),
|
||||
);
|
||||
gh.factory<_i1013.FerrisWheelLoaderBloc>(
|
||||
() => _i1013.FerrisWheelLoaderBloc(gh<_i96.IGameRepository>()),
|
||||
);
|
||||
gh.factory<_i744.GameSpinBloc>(
|
||||
() => _i744.GameSpinBloc(gh<_i96.IGameRepository>()),
|
||||
);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../common/extension/extension.dart';
|
||||
import '../../../common/theme/theme.dart';
|
||||
|
||||
@@ -4,6 +4,7 @@ class AppTextFormField extends StatelessWidget {
|
||||
const AppTextFormField({
|
||||
super.key,
|
||||
this.hintText,
|
||||
this.helperText,
|
||||
required this.title,
|
||||
this.controller,
|
||||
this.focusNode,
|
||||
@@ -14,9 +15,11 @@ class AppTextFormField extends StatelessWidget {
|
||||
this.validator,
|
||||
this.readOnly = false,
|
||||
this.onTap,
|
||||
this.inputFormatters,
|
||||
});
|
||||
|
||||
final String? hintText;
|
||||
final String? helperText;
|
||||
final String title;
|
||||
final TextEditingController? controller;
|
||||
final FocusNode? focusNode;
|
||||
@@ -27,6 +30,7 @@ class AppTextFormField extends StatelessWidget {
|
||||
final String? Function(String?)? validator;
|
||||
final bool readOnly;
|
||||
final Function()? onTap;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -39,6 +43,7 @@ class AppTextFormField extends StatelessWidget {
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
onChanged: onChanged,
|
||||
cursorColor: AppColor.primary,
|
||||
style: AppStyle.md.copyWith(
|
||||
@@ -50,6 +55,8 @@ class AppTextFormField extends StatelessWidget {
|
||||
readOnly: readOnly,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
helperText: helperText,
|
||||
helperStyle: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
prefixIcon: prefixIcon,
|
||||
suffixIcon: suffixIcon,
|
||||
prefixIconConstraints: const BoxConstraints(
|
||||
|
||||
@@ -16,7 +16,12 @@ import 'widgets/phone_field.dart';
|
||||
|
||||
@RoutePage()
|
||||
class LoginPage extends StatelessWidget implements AutoRouteWrapper {
|
||||
const LoginPage({super.key});
|
||||
const LoginPage({super.key, this.returnAfterLogin = false});
|
||||
|
||||
/// Login ulang dari tengah alur (mis. token kedaluwarsa saat main
|
||||
/// EnakGame): setelah berhasil, halaman login di-pop dengan hasil `true`
|
||||
/// alih-alih membuka beranda.
|
||||
final bool returnAfterLogin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -40,6 +45,7 @@ class LoginPage extends StatelessWidget implements AutoRouteWrapper {
|
||||
context.router.push(
|
||||
PasswordRoute(
|
||||
phoneNumber: getNormalizePhone(state.phoneNumber),
|
||||
returnAfterLogin: returnAfterLogin,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../../application/auth/check_phone_form/check_phone_form_bloc.dart';
|
||||
@@ -36,6 +37,9 @@ class _LoginPhoneFieldState extends State<LoginPhoneField> {
|
||||
|
||||
void _clearText() {
|
||||
_controller.clear();
|
||||
context.read<CheckPhoneFormBloc>().add(
|
||||
const CheckPhoneFormEvent.phoneNumberChanged(''),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@@ -44,13 +48,19 @@ class _LoginPhoneFieldState extends State<LoginPhoneField> {
|
||||
return AppTextFormField(
|
||||
title: 'Masukkan no telepon',
|
||||
hintText: '8712671212',
|
||||
helperText: 'Harus diawali angka 8',
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: [_PhoneAfterCountryCodeFormatter()],
|
||||
validator: (value) {
|
||||
if (context.read<CheckPhoneFormBloc>().state.phoneNumber.isEmpty) {
|
||||
final phoneNumber = context.read<CheckPhoneFormBloc>().state.phoneNumber;
|
||||
if (phoneNumber.isEmpty) {
|
||||
return 'Masukkan no telepon';
|
||||
}
|
||||
if (!phoneNumber.startsWith('8')) {
|
||||
return 'No telepon harus diawali angka 8';
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
@@ -80,3 +90,27 @@ class _LoginPhoneFieldState extends State<LoginPhoneField> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefix +62 sudah tampil di field, jadi input harus langsung diawali 8.
|
||||
/// Ketikan `0`/`6` di awal ditolak; tempelan `08…`, `62…`, atau `+62…`
|
||||
/// dirapikan jadi `8…`.
|
||||
class _PhoneAfterCountryCodeFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
var digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||
if (digits.startsWith('62')) digits = digits.substring(2);
|
||||
digits = digits.replaceFirst(RegExp(r'^0+'), '');
|
||||
|
||||
if (digits.isEmpty) return const TextEditingValue();
|
||||
if (!digits.startsWith('8')) return oldValue;
|
||||
if (digits == newValue.text) return newValue;
|
||||
|
||||
return TextEditingValue(
|
||||
text: digits,
|
||||
selection: TextSelection.collapsed(offset: digits.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,14 @@ import '../../../router/app_router.gr.dart';
|
||||
@RoutePage()
|
||||
class PasswordPage extends StatelessWidget implements AutoRouteWrapper {
|
||||
final String phoneNumber;
|
||||
const PasswordPage({super.key, required this.phoneNumber});
|
||||
|
||||
/// Lihat [LoginPage.returnAfterLogin].
|
||||
final bool returnAfterLogin;
|
||||
const PasswordPage({
|
||||
super.key,
|
||||
required this.phoneNumber,
|
||||
this.returnAfterLogin = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -28,6 +35,12 @@ class PasswordPage extends StatelessWidget implements AutoRouteWrapper {
|
||||
AppFlushbar.showSuccess(context, data.message);
|
||||
Future.delayed(Duration(milliseconds: 1000), () {
|
||||
context.read<AuthBloc>().add(AuthEvent.fetchCurrentUser());
|
||||
if (returnAfterLogin) {
|
||||
final router = context.router;
|
||||
router.popUntilRouteWithName(LoginRoute.name);
|
||||
router.pop(true);
|
||||
return;
|
||||
}
|
||||
context.router.replaceAll([MainRoute()]);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
|
||||
|
||||
import '../../../application/wallet/wallet_loader/wallet_loader_bloc.dart';
|
||||
import '../../../common/constant/app_constant.dart';
|
||||
import '../../../common/constant/local_storage_key.dart';
|
||||
import '../../../common/theme/theme.dart';
|
||||
import '../../../env.dart';
|
||||
import '../../../injection.dart';
|
||||
import '../../components/button/button.dart';
|
||||
import '../../router/app_router.gr.dart';
|
||||
import '../wallet/widgets/wallet_confirm_dialog.dart';
|
||||
|
||||
/// EnakGame di webview layar penuh: web game.enaklo.com yang menampilkan
|
||||
/// daftar game, memulai session, memotong EnakCoin, dan menampilkan hadiah.
|
||||
/// Aplikasi hanya memberi token lewat bridge (§8.3) dan tidak pernah
|
||||
/// menaruhnya di URL.
|
||||
@RoutePage()
|
||||
class EnakGamePage extends StatefulWidget {
|
||||
const EnakGamePage({super.key});
|
||||
|
||||
@override
|
||||
State<EnakGamePage> createState() => _EnakGamePageState();
|
||||
}
|
||||
|
||||
class _EnakGamePageState extends State<EnakGamePage> {
|
||||
/// Game → aplikasi: JavaScript channel dengan pesan berupa JSON string.
|
||||
static const String _hostChannel = 'EnakGameHost';
|
||||
|
||||
static const _nativeChannel = MethodChannel(
|
||||
'com.apskel.enaklocustomer/webview',
|
||||
);
|
||||
|
||||
late final WebViewController _controller;
|
||||
|
||||
/// Lewat notifier, bukan setState, supaya WebView tidak ikut di-rebuild
|
||||
/// setiap progres berubah.
|
||||
final ValueNotifier<int> _progress = ValueNotifier(0);
|
||||
bool _hasError = false;
|
||||
bool _isRelogging = false;
|
||||
bool _isExiting = false;
|
||||
|
||||
String? get _token =>
|
||||
getIt<SharedPreferences>().getString(LocalStorageKey.token);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = WebViewController();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_progress.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
await _controller.setJavaScriptMode(JavaScriptMode.unrestricted);
|
||||
await _controller.setBackgroundColor(AppConstant.gameBackgroundColor);
|
||||
await _controller.setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onProgress: (progress) => _progress.value = progress,
|
||||
onPageStarted: (_) {
|
||||
if (mounted && _hasError) setState(() => _hasError = false);
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
// Error sub-resource (gambar, script pihak ketiga) diabaikan.
|
||||
if (error.isForMainFrame == false) return;
|
||||
if (mounted) setState(() => _hasError = true);
|
||||
},
|
||||
onNavigationRequest: _onNavigationRequest,
|
||||
),
|
||||
);
|
||||
// Bridge dipasang sebelum halaman dimuat.
|
||||
await _controller.addJavaScriptChannel(
|
||||
_hostChannel,
|
||||
onMessageReceived: _onMessage,
|
||||
);
|
||||
|
||||
final platform = _controller.platform;
|
||||
if (platform is AndroidWebViewController) {
|
||||
await _enableOffscreenPreRaster(platform);
|
||||
}
|
||||
// Swipe dari tepi kiri di iOS untuk mundur di riwayat web.
|
||||
if (platform is WebKitWebViewController) {
|
||||
await platform.setAllowsBackForwardNavigationGestures(true);
|
||||
}
|
||||
|
||||
await _controller.loadRequest(Uri.parse(AppConstant.gameUrl));
|
||||
} catch (e, s) {
|
||||
log('load', name: 'EnakGamePage', error: e, stackTrace: s);
|
||||
if (mounted) setState(() => _hasError = true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Daftar game di web di-scroll; tanpa pre-raster, Android WebView
|
||||
/// menggambar ulang bagian yang kembali masuk layar. Lihat `MainActivity.kt`.
|
||||
Future<void> _enableOffscreenPreRaster(
|
||||
AndroidWebViewController platform,
|
||||
) async {
|
||||
try {
|
||||
await _nativeChannel.invokeMethod('enableOffscreenPreRaster', {
|
||||
'webViewId': platform.webViewIdentifier,
|
||||
});
|
||||
} on PlatformException catch (e, s) {
|
||||
log('enableOffscreenPreRaster', error: e, stackTrace: s);
|
||||
}
|
||||
}
|
||||
|
||||
void _onMessage(JavaScriptMessage message) {
|
||||
final Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(message.message);
|
||||
} on FormatException {
|
||||
return;
|
||||
}
|
||||
if (decoded is! Map) return;
|
||||
|
||||
switch (decoded['type']) {
|
||||
case 'ready':
|
||||
_sendInit();
|
||||
case 'token_expired':
|
||||
_relogin();
|
||||
case 'balance_changed':
|
||||
final coinBalance = decoded['coin_balance'];
|
||||
if (coinBalance is num) {
|
||||
context.read<WalletLoaderBloc>().add(
|
||||
WalletLoaderEvent.coinBalanceUpdated(coinBalance.toInt()),
|
||||
);
|
||||
}
|
||||
case 'close':
|
||||
if (!_isRelogging) _exit();
|
||||
}
|
||||
// Tipe lain diabaikan.
|
||||
}
|
||||
|
||||
/// Tanpa `game_id`: game dipilih customer di web, bukan di aplikasi.
|
||||
void _sendInit() {
|
||||
final token = _token;
|
||||
if (token == null) {
|
||||
_relogin();
|
||||
return;
|
||||
}
|
||||
_send({
|
||||
'type': 'init',
|
||||
'api_base_url': '${getIt<Env>().baseUrl}/api/v1',
|
||||
'token': token,
|
||||
});
|
||||
}
|
||||
|
||||
/// Aplikasi → game: `window.enakGame.receive('<json>')`.
|
||||
Future<void> _send(Map<String, Object?> message) async {
|
||||
// Encode dua kali: JSON pesan dijadikan string literal JavaScript.
|
||||
final argument = jsonEncode(jsonEncode(message));
|
||||
try {
|
||||
await _controller.runJavaScript(
|
||||
'window.enakGame && window.enakGame.receive($argument);',
|
||||
);
|
||||
} catch (e) {
|
||||
// Pesan berisi token: jangan ikut dicatat.
|
||||
log('send ${message['type']} failed', name: 'EnakGamePage');
|
||||
}
|
||||
}
|
||||
|
||||
/// Belum ada refresh token: customer login ulang, lalu token baru dikirim
|
||||
/// ke game. Bila batal, game ditutup.
|
||||
Future<void> _relogin() async {
|
||||
if (_isRelogging || _isExiting) return;
|
||||
_isRelogging = true;
|
||||
|
||||
final proceed = await showWalletConfirmDialog(
|
||||
context,
|
||||
title: 'Sesi berakhir',
|
||||
message: 'Masuk lagi untuk melanjutkan permainan.',
|
||||
confirmLabel: 'Masuk',
|
||||
);
|
||||
final loggedIn =
|
||||
proceed &&
|
||||
mounted &&
|
||||
await context.router.push<bool>(LoginRoute(returnAfterLogin: true)) ==
|
||||
true;
|
||||
|
||||
_isRelogging = false;
|
||||
if (!mounted) return;
|
||||
|
||||
final token = _token;
|
||||
if (loggedIn && token != null) {
|
||||
_send({'type': 'token', 'token': token});
|
||||
} else {
|
||||
_exit();
|
||||
}
|
||||
}
|
||||
|
||||
/// Menutup game lalu memuat ulang saldo, karena EnakCoin bisa berubah
|
||||
/// selama main.
|
||||
void _exit() {
|
||||
if (_isExiting) return;
|
||||
_isExiting = true;
|
||||
context.read<WalletLoaderBloc>().add(const WalletLoaderEvent.fetched());
|
||||
|
||||
// Dialog yang masih terbuka (mis. konfirmasi keluar) ikut ditutup.
|
||||
final navigator = Navigator.of(context);
|
||||
final route = ModalRoute.of(context);
|
||||
navigator.popUntil((r) => r == route);
|
||||
navigator.pop();
|
||||
}
|
||||
|
||||
/// Tombol back Android mundur di riwayat web dulu. Di halaman pertama,
|
||||
/// game tidak langsung ditutup: minta konfirmasi.
|
||||
Future<void> _onPopInvoked(bool didPop, Object? result) async {
|
||||
if (didPop || _isExiting) return;
|
||||
if (_hasError) {
|
||||
_exit();
|
||||
return;
|
||||
}
|
||||
if (await _controller.canGoBack()) {
|
||||
await _controller.goBack();
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
final leave = await showWalletConfirmDialog(
|
||||
context,
|
||||
title: 'Keluar dari game?',
|
||||
message: 'EnakCoin yang sudah dipakai untuk main tidak kembali.',
|
||||
confirmLabel: 'Keluar',
|
||||
);
|
||||
if (leave && mounted) _exit();
|
||||
}
|
||||
|
||||
/// Skema selain http/https (tel:, mailto:, whatsapp:, intent:) diserahkan
|
||||
/// ke aplikasi luar karena WebView tidak bisa membukanya.
|
||||
Future<NavigationDecision> _onNavigationRequest(
|
||||
NavigationRequest request,
|
||||
) async {
|
||||
final uri = Uri.tryParse(request.url);
|
||||
if (uri == null || uri.scheme == 'http' || uri.scheme == 'https') {
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
setState(() => _hasError = false);
|
||||
_controller.reload();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value:
|
||||
(_hasError ? SystemUiOverlayStyle.dark : SystemUiOverlayStyle.light)
|
||||
.copyWith(statusBarColor: Colors.transparent),
|
||||
child: PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: _onPopInvoked,
|
||||
child: Scaffold(
|
||||
backgroundColor: AppConstant.gameBackgroundColor,
|
||||
// WebView tetap terpasang saat error, supaya "Coba lagi" tidak
|
||||
// membuat ulang native view-nya.
|
||||
body: Stack(
|
||||
children: [
|
||||
_buildWebView(),
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: _buildProgress(),
|
||||
),
|
||||
if (_hasError) Positioned.fill(child: _buildError()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Android memakai Hybrid Composition: WebView digambar langsung oleh
|
||||
/// Android, jauh lebih lancar untuk game yang terus beranimasi.
|
||||
Widget _buildWebView() {
|
||||
final platform = _controller.platform;
|
||||
return WebViewWidget.fromPlatformCreationParams(
|
||||
params: platform is AndroidWebViewController
|
||||
? AndroidWebViewWidgetCreationParams(
|
||||
controller: platform,
|
||||
displayWithHybridComposition: true,
|
||||
)
|
||||
: PlatformWebViewWidgetCreationParams(controller: platform),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgress() {
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: _progress,
|
||||
builder: (context, progress, _) => progress < 100 && !_hasError
|
||||
? LinearProgressIndicator(
|
||||
value: progress / 100,
|
||||
minHeight: 2,
|
||||
color: AppColor.primary,
|
||||
backgroundColor: AppColor.primaryWithOpacity(0.1),
|
||||
)
|
||||
: const SizedBox(height: 2),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError() {
|
||||
return ColoredBox(
|
||||
color: AppColor.white,
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.wifi_off,
|
||||
size: 56,
|
||||
color: AppColor.textSecondary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Game tidak bisa dimuat. Periksa koneksi internet kamu.',
|
||||
style: AppStyle.md,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppOutlineButton(
|
||||
title: 'Coba lagi',
|
||||
width: 160,
|
||||
onPressed: _reload,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 4,
|
||||
left: 4,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: 'Tutup',
|
||||
onPressed: _exit,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,11 @@ class _MainPageState extends State<MainPage> {
|
||||
context.read<AuthBloc>().add(const AuthEvent.fetchCurrentUser());
|
||||
}
|
||||
|
||||
/// Kode bayar di kasir; PIN diminta di halamannya sendiri.
|
||||
void _openPay() {
|
||||
/// Beranda EnakPoint & EnakCoin: tukar, transfer, game, dan voucher.
|
||||
void _openWallet() {
|
||||
final isAuthenticated = context.read<AuthBloc>().state.isAuthenticated;
|
||||
context.router.push(
|
||||
isAuthenticated ? const PaymentCodeRoute() : const OnboardingRoute(),
|
||||
isAuthenticated ? const WalletRoute() : const OnboardingRoute(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,11 +48,11 @@ class _MainPageState extends State<MainPage> {
|
||||
body: child,
|
||||
floatingActionButton: isKeyboardOpen
|
||||
? null
|
||||
: MainPayButton(onPressed: _openPay),
|
||||
floatingActionButtonLocation: const MainPayButtonLocation(),
|
||||
: MainWalletButton(onPressed: _openWallet),
|
||||
floatingActionButtonLocation: const MainWalletButtonLocation(),
|
||||
bottomNavigationBar: MainBottomNavbar(
|
||||
tabsRouter: AutoTabsRouter.of(context),
|
||||
onPayTap: _openPay,
|
||||
onWalletTap: _openWallet,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -30,10 +30,10 @@ class _HomePageState extends State<HomePage> {
|
||||
CarouselSliderController();
|
||||
|
||||
final List<String> _carouselImages = [
|
||||
'https://images.unsplash.com/photo-1509042239860-f550ce710b93?w=800&h=400&fit=crop',
|
||||
'https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:enaklo/public/03205d8f-2c06-4513-a952-21d1109f78ff_ramen_compressed_around_1mb(1)(1).jpg',
|
||||
'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?w=800&h=400&fit=crop',
|
||||
'https://images.unsplash.com/photo-1461023058943-07fcbe16d735?w=800&h=400&fit=crop',
|
||||
'https://images.unsplash.com/photo-1574848794584-c740d6a5595f?w=800&h=400&fit=crop',
|
||||
'https://i.postimg.cc/YSx4jK2H/masakan-daerah-enhanced-under-2mb.jpg',
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -150,7 +150,6 @@ class _HomePageState extends State<HomePage> {
|
||||
},
|
||||
),
|
||||
items: _carouselImages
|
||||
.skip(1)
|
||||
.map((imageUrl) => _buildImageSlide(imageUrl))
|
||||
.toList(),
|
||||
);
|
||||
@@ -181,7 +180,7 @@ class _HomePageState extends State<HomePage> {
|
||||
Widget _buildCarouselIndicators() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(4, (index) {
|
||||
children: List.generate(_carouselImages.length, (index) {
|
||||
return GestureDetector(
|
||||
onTap: () => _carouselController.animateToPage(index),
|
||||
child: Container(
|
||||
|
||||
@@ -44,7 +44,7 @@ class HomePointCard extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
isAuthenticated
|
||||
? 'Bayar di kasir, tukar, transfer & main game'
|
||||
? 'Tukar voucher, transfer & main game'
|
||||
: 'Silahkan login untuk melihat EnakPoint kamu',
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../../../application/auth/auth_bloc.dart';
|
||||
import '../../../../../../common/constant/app_constant.dart';
|
||||
import '../../../../../../common/theme/theme.dart';
|
||||
import '../../../../../components/assets/assets.gen.dart';
|
||||
import '../../../../../components/toast/flushbar.dart';
|
||||
@@ -38,12 +37,8 @@ class HomeServiceSection extends StatelessWidget {
|
||||
_HomeMenu(
|
||||
label: 'EnakGame',
|
||||
icon: Assets.icons.enakgameIcon,
|
||||
route: WebViewRoute(
|
||||
title: 'EnakGame',
|
||||
url: AppConstant.gameUrl,
|
||||
fullscreen: true,
|
||||
backgroundColor: AppConstant.gameBackgroundColor,
|
||||
),
|
||||
route: const EnakGameRoute(),
|
||||
requiresLogin: true,
|
||||
),
|
||||
_HomeMenu(
|
||||
label: 'EnakTransfer',
|
||||
@@ -55,6 +50,7 @@ class HomeServiceSection extends StatelessWidget {
|
||||
label: 'EnakReward',
|
||||
icon: Assets.icons.enakrewardIcon,
|
||||
route: const RewardRoute(),
|
||||
requiresLogin: true,
|
||||
),
|
||||
_HomeMenu(
|
||||
label: 'EnakPromo',
|
||||
|
||||
@@ -5,29 +5,29 @@ import '../../../../common/theme/theme.dart';
|
||||
|
||||
class MainBottomNavbar extends StatelessWidget {
|
||||
final TabsRouter tabsRouter;
|
||||
final VoidCallback onPayTap;
|
||||
final VoidCallback onWalletTap;
|
||||
const MainBottomNavbar({
|
||||
super.key,
|
||||
required this.tabsRouter,
|
||||
required this.onPayTap,
|
||||
required this.onWalletTap,
|
||||
});
|
||||
|
||||
/// Slot tengah hanya tempat label tombol Bayar; lingkarannya
|
||||
/// [MainPayButton] yang menempel di atas navbar.
|
||||
static const int _payIndex = 2;
|
||||
/// Slot tengah hanya tempat label tombol Poin; lingkarannya
|
||||
/// [MainWalletButton] yang menempel di atas navbar.
|
||||
static const int _walletIndex = 2;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activeIndex = tabsRouter.activeIndex;
|
||||
|
||||
return BottomNavigationBar(
|
||||
currentIndex: activeIndex >= _payIndex ? activeIndex + 1 : activeIndex,
|
||||
currentIndex: activeIndex >= _walletIndex ? activeIndex + 1 : activeIndex,
|
||||
onTap: (index) {
|
||||
if (index == _payIndex) {
|
||||
onPayTap();
|
||||
if (index == _walletIndex) {
|
||||
onWalletTap();
|
||||
return;
|
||||
}
|
||||
tabsRouter.setActiveIndex(index > _payIndex ? index - 1 : index);
|
||||
tabsRouter.setActiveIndex(index > _walletIndex ? index - 1 : index);
|
||||
},
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
@@ -47,8 +47,8 @@ class MainBottomNavbar extends StatelessWidget {
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: SizedBox(height: 24),
|
||||
label: 'Bayar',
|
||||
tooltip: 'Bayar di kasir',
|
||||
label: 'Poin',
|
||||
tooltip: 'EnakPoint & EnakCoin',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.groups),
|
||||
@@ -66,10 +66,10 @@ class MainBottomNavbar extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// Seperti [FloatingActionButtonLocation.centerDocked], tapi tombolnya lebih
|
||||
/// menonjol ke atas supaya tidak menempel ke label "Bayar".
|
||||
class MainPayButtonLocation extends StandardFabLocation
|
||||
/// menonjol ke atas supaya tidak menempel ke label "Poin".
|
||||
class MainWalletButtonLocation extends StandardFabLocation
|
||||
with FabCenterOffsetX, FabDockedOffsetY {
|
||||
const MainPayButtonLocation();
|
||||
const MainWalletButtonLocation();
|
||||
|
||||
static const double _raise = 10;
|
||||
|
||||
@@ -80,10 +80,10 @@ class MainPayButtonLocation extends StandardFabLocation
|
||||
) => super.getOffsetY(scaffoldGeometry, adjustment) - _raise;
|
||||
}
|
||||
|
||||
/// Tombol bulat di tengah navbar, dipasang di [MainPayButtonLocation].
|
||||
class MainPayButton extends StatelessWidget {
|
||||
/// Tombol bulat di tengah navbar, dipasang di [MainWalletButtonLocation].
|
||||
class MainWalletButton extends StatelessWidget {
|
||||
final VoidCallback onPressed;
|
||||
const MainPayButton({super.key, required this.onPressed});
|
||||
const MainWalletButton({super.key, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -92,12 +92,12 @@ class MainPayButton extends StatelessWidget {
|
||||
// Halaman tab (mis. Community) juga punya FAB; tanpa hero supaya tag
|
||||
// default tidak bentrok saat pindah halaman.
|
||||
heroTag: null,
|
||||
tooltip: 'Bayar di kasir',
|
||||
tooltip: 'EnakPoint & EnakCoin',
|
||||
elevation: 4,
|
||||
backgroundColor: AppColor.primary,
|
||||
foregroundColor: AppColor.white,
|
||||
shape: const CircleBorder(),
|
||||
child: const Icon(Icons.qr_code_scanner_rounded, size: 28),
|
||||
child: const Icon(Icons.stars_rounded, size: 30),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class WheelSection {
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
final String prize;
|
||||
final int value;
|
||||
|
||||
WheelSection({
|
||||
required this.color,
|
||||
required this.icon,
|
||||
required this.prize,
|
||||
required this.value,
|
||||
});
|
||||
}
|
||||
|
||||
class PrizeHistory {
|
||||
final String prize;
|
||||
final DateTime dateTime;
|
||||
final int value;
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
final String? gamePrizeId; // Added for API integration
|
||||
|
||||
PrizeHistory({
|
||||
required this.prize,
|
||||
required this.dateTime,
|
||||
required this.value,
|
||||
required this.color,
|
||||
required this.icon,
|
||||
this.gamePrizeId,
|
||||
});
|
||||
|
||||
// CopyWith method for updating properties
|
||||
PrizeHistory copyWith({
|
||||
String? prize,
|
||||
DateTime? dateTime,
|
||||
int? value,
|
||||
Color? color,
|
||||
IconData? icon,
|
||||
String? gamePrizeId,
|
||||
}) {
|
||||
return PrizeHistory(
|
||||
prize: prize ?? this.prize,
|
||||
dateTime: dateTime ?? this.dateTime,
|
||||
value: value ?? this.value,
|
||||
color: color ?? this.color,
|
||||
icon: icon ?? this.icon,
|
||||
gamePrizeId: gamePrizeId ?? this.gamePrizeId,
|
||||
);
|
||||
}
|
||||
|
||||
// Convert to Map for storage/API calls
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'prize': prize,
|
||||
'dateTime': dateTime.toIso8601String(),
|
||||
'value': value,
|
||||
'gamePrizeId': gamePrizeId,
|
||||
};
|
||||
}
|
||||
|
||||
// Create from Map for loading from storage/API
|
||||
factory PrizeHistory.fromMap(
|
||||
Map<String, dynamic> map, {
|
||||
required Color color,
|
||||
required IconData icon,
|
||||
}) {
|
||||
return PrizeHistory(
|
||||
prize: map['prize'] ?? '',
|
||||
dateTime: DateTime.tryParse(map['dateTime'] ?? '') ?? DateTime.now(),
|
||||
value: map['value'] ?? 0,
|
||||
color: color,
|
||||
icon: icon,
|
||||
gamePrizeId: map['gamePrizeId'],
|
||||
);
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
String get formattedDate {
|
||||
return '${dateTime.day.toString().padLeft(2, '0')}/${dateTime.month.toString().padLeft(2, '0')}/${dateTime.year} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
// Get prize category based on value
|
||||
String get prizeCategory {
|
||||
if (value >= 1000000) return 'Jackpot';
|
||||
if (value >= 100000) return 'Big Prize';
|
||||
if (value >= 10000) return 'Medium Prize';
|
||||
if (value >= 1000) return 'Small Prize';
|
||||
return 'Token/Spin';
|
||||
}
|
||||
|
||||
// Get prize category color
|
||||
Color get categoryColor {
|
||||
if (value >= 1000000) return const Color(0xFFFFD700); // Gold
|
||||
if (value >= 100000) return const Color(0xFFC0C0C0); // Silver
|
||||
if (value >= 10000) return const Color(0xFFCD7F32); // Bronze
|
||||
return color; // Default color
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -173,7 +173,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
const Spacer(),
|
||||
|
||||
AppElevatedButton(
|
||||
onPressed: () => context.router.push(const LoginRoute()),
|
||||
onPressed: () => context.router.push(LoginRoute()),
|
||||
title: 'Masuk',
|
||||
),
|
||||
|
||||
|
||||
@@ -437,26 +437,17 @@ class _PaymentRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Pembayaran EnakPoint: "EnakPoint 12.500 (Rp 12.500)".
|
||||
final title = payment.isPoint && payment.pointsUsed != null
|
||||
? '${WalletCurrency.point.label} ${payment.pointsUsed!.thousandFormat}'
|
||||
: payment.methodName;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (payment.isPoint) ...[
|
||||
const WalletCurrencyIcon(currency: WalletCurrency.point, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
payment.methodName,
|
||||
style: AppStyle.sm.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (payment.refundAmount > 0)
|
||||
@@ -468,9 +459,7 @@ class _PaymentRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
payment.isPoint
|
||||
? '(${payment.amount.currencyFormatRp})'
|
||||
: payment.amount.currencyFormatRp,
|
||||
payment.amount.currencyFormatRp,
|
||||
style: AppStyle.sm.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -53,24 +53,12 @@ class OutletTile extends StatelessWidget {
|
||||
style: AppStyle.xs.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
],
|
||||
if (outlet.acceptsPointPayment ||
|
||||
outlet.earnsPoints ||
|
||||
outlet.earnsCoins) ...[
|
||||
if (outlet.earnsPoints || outlet.earnsCoins) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
if (outlet.acceptsPointPayment)
|
||||
const _Chip(
|
||||
label: 'Bisa bayar pakai EnakPoint',
|
||||
icon: Icon(
|
||||
Icons.check_circle_rounded,
|
||||
size: 14,
|
||||
color: AppColor.primary,
|
||||
),
|
||||
highlighted: true,
|
||||
),
|
||||
if (outlet.earnsPoints)
|
||||
const _Chip(
|
||||
label: 'Dapat EnakPoint',
|
||||
@@ -100,24 +88,17 @@ class OutletTile extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _Chip extends StatelessWidget {
|
||||
const _Chip({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
this.highlighted = false,
|
||||
});
|
||||
const _Chip({required this.label, required this.icon});
|
||||
|
||||
final String label;
|
||||
final Widget icon;
|
||||
final bool highlighted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: highlighted
|
||||
? AppColor.primaryWithOpacity(0.08)
|
||||
: AppColor.background,
|
||||
color: AppColor.background,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
@@ -129,7 +110,7 @@ class _Chip extends StatelessWidget {
|
||||
label,
|
||||
style: AppStyle.xs.copyWith(
|
||||
fontSize: 10,
|
||||
color: highlighted ? AppColor.primary : AppColor.textPrimary,
|
||||
color: AppColor.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -79,9 +79,9 @@ class _PinSettingsPageState extends State<PinSettingsPage> {
|
||||
borderRadius: BorderRadius.circular(AppValue.borderRadius),
|
||||
),
|
||||
child: Text(
|
||||
'PIN 6 digit dipakai untuk membuat kode bayar, tukar '
|
||||
'EnakCoin, dan transfer. PIN berbeda dengan password '
|
||||
'login dan jangan diberitahukan kepada siapa pun.',
|
||||
'PIN 6 digit dipakai untuk tukar EnakCoin, transfer, dan '
|
||||
'tukar voucher. PIN berbeda dengan password login dan '
|
||||
'jangan diberitahukan kepada siapa pun.',
|
||||
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -189,8 +189,8 @@ class _PinSetupPageState extends State<PinSetupPage> {
|
||||
_Step.otp =>
|
||||
'Kami mengirim kode 6 digit ke WhatsApp nomor kamu yang terdaftar.',
|
||||
_Step.pin =>
|
||||
'PIN 6 digit dipakai untuk membuat kode bayar, tukar EnakCoin, dan '
|
||||
'transfer. PIN berbeda dengan password login.',
|
||||
'PIN 6 digit dipakai untuk tukar EnakCoin, transfer, dan tukar '
|
||||
'voucher. PIN berbeda dengan password login.',
|
||||
_Step.confirm => 'Masukkan kembali PIN yang sama.',
|
||||
_Step.done => '',
|
||||
};
|
||||
@@ -318,9 +318,9 @@ class _PinSetupPageState extends State<PinSetupPage> {
|
||||
Text(
|
||||
_isReset
|
||||
? 'Demi keamanan, transfer keluar ditahan selama 24 jam. '
|
||||
'Kode bayar dan tukar EnakCoin tetap bisa dipakai.'
|
||||
: 'Gunakan PIN ini untuk membuat kode bayar, tukar EnakCoin, '
|
||||
'dan transfer. Jangan beritahu PIN kepada siapa pun.',
|
||||
'Tukar EnakCoin dan tukar voucher tetap bisa dipakai.'
|
||||
: 'Gunakan PIN ini untuk tukar EnakCoin, transfer, dan tukar '
|
||||
'voucher. Jangan beritahu PIN kepada siapa pun.',
|
||||
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../../application/auth/auth_bloc.dart';
|
||||
import '../../../../../common/theme/theme.dart';
|
||||
import '../../../../../sample/promo_sample_data.dart';
|
||||
import '../../../../components/button/button.dart';
|
||||
@@ -98,9 +100,14 @@ class PromoDetailPage extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: AppElevatedButton(
|
||||
title: isReward ? 'Lihat Reward Saya' : 'Cari Outlet',
|
||||
title: isReward ? 'Buka EnakReward' : 'Cari Outlet',
|
||||
// Voucher dan outlet memakai API customer: tamu masuk dulu.
|
||||
onPressed: () => context.router.push(
|
||||
isReward ? const RewardRoute() : const OutletRoute(),
|
||||
!context.read<AuthBloc>().state.isAuthenticated
|
||||
? const OnboardingRoute()
|
||||
: isReward
|
||||
? const RewardRoute()
|
||||
: const OutletRoute(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../application/voucher/voucher_redeem_form/voucher_redeem_form_bloc.dart';
|
||||
import '../../../application/wallet/wallet_loader/wallet_loader_bloc.dart';
|
||||
import '../../../common/extension/extension.dart';
|
||||
import '../../../common/theme/theme.dart';
|
||||
import '../../../domain/wallet/wallet.dart';
|
||||
import '../../../injection.dart';
|
||||
import '../../components/button/button.dart';
|
||||
import '../../components/toast/flushbar.dart';
|
||||
import '../pin/pin_guard.dart';
|
||||
import '../pin/widgets/pin_sheet.dart';
|
||||
import '../wallet/wallet_ui.dart';
|
||||
import '../wallet/widgets/wallet_confirm_dialog.dart';
|
||||
import 'reward_ui.dart';
|
||||
|
||||
/// Detail voucher di katalog dan tukar EnakPoint ke voucher (§9.2). Pop
|
||||
/// dengan `true` bila voucher berhasil ditukar.
|
||||
@RoutePage()
|
||||
class RewardDetailPage extends StatefulWidget implements AutoRouteWrapper {
|
||||
const RewardDetailPage({super.key, required this.voucher});
|
||||
|
||||
final Voucher voucher;
|
||||
|
||||
@override
|
||||
State<RewardDetailPage> createState() => _RewardDetailPageState();
|
||||
|
||||
@override
|
||||
Widget wrappedRoute(BuildContext context) =>
|
||||
BlocProvider(create: (_) => getIt<VoucherRedeemFormBloc>(), child: this);
|
||||
}
|
||||
|
||||
class _RewardDetailPageState extends State<RewardDetailPage> {
|
||||
/// Diisi bila server menjawab voucher habis / tidak tersedia; katalog
|
||||
/// dimuat ulang saat kembali ke halaman sebelumnya.
|
||||
String? _unavailableMessage;
|
||||
|
||||
Voucher get _voucher => widget.voucher;
|
||||
|
||||
Future<void> _redeem() async {
|
||||
final confirmed = await showWalletConfirmDialog(
|
||||
context,
|
||||
title: 'Tukar voucher?',
|
||||
message:
|
||||
'Tukar ${_voucher.pointCost.thousandFormat} EnakPoint dengan '
|
||||
'${_voucher.name}? Tidak bisa dibatalkan.',
|
||||
confirmLabel: 'Tukar',
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
if (!await ensurePinReady(context) || !mounted) return;
|
||||
|
||||
final success = await showPinSheet(
|
||||
context,
|
||||
title: 'Tukar voucher',
|
||||
onSubmit: (pin) async {
|
||||
final bloc = context.read<VoucherRedeemFormBloc>();
|
||||
bloc.add(
|
||||
VoucherRedeemFormEvent.submitted(voucherId: _voucher.id, pin: pin),
|
||||
);
|
||||
final result = await bloc.stream.firstWhere((s) => !s.isSubmitting);
|
||||
return result.failureOption.toNullable();
|
||||
},
|
||||
onFailure: _showFailure,
|
||||
);
|
||||
|
||||
if (success && mounted) {
|
||||
context.read<WalletLoaderBloc>().add(const WalletLoaderEvent.fetched());
|
||||
}
|
||||
}
|
||||
|
||||
void _showFailure(WalletFailure failure) {
|
||||
if (failure.isPinError) return;
|
||||
|
||||
if (failure.isNetworkError) {
|
||||
AppFlushbar.showError(
|
||||
context,
|
||||
'Koneksi terputus, status penukaran belum diketahui. Tekan Tukar lagi '
|
||||
'untuk mengulang. EnakPoint tidak akan terpotong dua kali.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final description = describeVoucherRedeemFailure(failure);
|
||||
if (description.reloadCatalog) {
|
||||
setState(() => _unavailableMessage = description.message);
|
||||
}
|
||||
AppFlushbar.showError(context, description.message);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<VoucherRedeemFormBloc, VoucherRedeemFormState>(
|
||||
builder: (context, state) {
|
||||
final result = state.result;
|
||||
return PopScope(
|
||||
canPop: result == null,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop && result != null) context.router.pop(true);
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: AppColor.background,
|
||||
appBar: AppBar(title: const Text('Detail voucher')),
|
||||
body: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: result != null
|
||||
? KeyedSubtree(
|
||||
key: const ValueKey('success'),
|
||||
child: _buildSuccess(result),
|
||||
)
|
||||
: KeyedSubtree(
|
||||
key: const ValueKey('detail'),
|
||||
child: _buildDetail(state),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetail(VoucherRedeemFormState state) {
|
||||
final faceValue = voucherFaceValueLabel(_voucher.type, _voucher.faceValue);
|
||||
|
||||
return BlocBuilder<WalletLoaderBloc, WalletLoaderState>(
|
||||
builder: (context, walletState) {
|
||||
final pointBalance = walletState.summary.pointBalance;
|
||||
final canAfford =
|
||||
!walletState.hasLoaded || pointBalance >= _voucher.pointCost;
|
||||
|
||||
final String? blockedLabel = _unavailableMessage != null
|
||||
? 'Voucher tidak tersedia'
|
||||
: _voucher.isOutOfStock
|
||||
? 'Habis'
|
||||
: !canAfford
|
||||
? 'EnakPoint kurang'
|
||||
: null;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppValue.borderRadius,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
VoucherImage(
|
||||
url: _voucher.imageUrl,
|
||||
size: 120,
|
||||
dimmed:
|
||||
_voucher.isOutOfStock ||
|
||||
_unavailableMessage != null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
voucherTypeLabel(
|
||||
_voucher.type,
|
||||
_voucher.faceValue,
|
||||
).toUpperCase(),
|
||||
style: AppStyle.xs.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_voucher.name,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.xl.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (faceValue != null)
|
||||
Text(
|
||||
faceValue,
|
||||
style: AppStyle.md.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
),
|
||||
),
|
||||
if (_voucher.description != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_voucher.description!,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppValue.borderRadius,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_InfoRow(
|
||||
label: 'Ditukar dengan',
|
||||
value:
|
||||
'${_voucher.pointCost.thousandFormat} EnakPoint',
|
||||
),
|
||||
if (walletState.hasLoaded)
|
||||
_InfoRow(
|
||||
label: 'Saldo EnakPoint kamu',
|
||||
value: pointBalance.thousandFormat,
|
||||
valueColor: canAfford ? null : AppColor.error,
|
||||
),
|
||||
if (_voucher.validUntil != null)
|
||||
_InfoRow(
|
||||
label: 'Bisa ditukar sampai',
|
||||
value: formatWibDateTime(_voucher.validUntil!),
|
||||
),
|
||||
if (_voucher.available != null)
|
||||
_InfoRow(
|
||||
label: 'Sisa stok',
|
||||
value: _voucher.isOutOfStock
|
||||
? 'Habis'
|
||||
: _voucher.available!.thousandFormat,
|
||||
),
|
||||
if (_voucher.maxPerCustomer != null)
|
||||
_InfoRow(
|
||||
label: 'Batas tukar',
|
||||
value:
|
||||
'${_voucher.maxPerCustomer!.thousandFormat}× '
|
||||
'per customer',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: AppColor.white,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: AppElevatedButton(
|
||||
title:
|
||||
blockedLabel ??
|
||||
'Tukar ${_voucher.pointCost.thousandFormat} EnakPoint',
|
||||
height: 48,
|
||||
isLoading: state.isSubmitting,
|
||||
onPressed: blockedLabel == null && !state.isSubmitting
|
||||
? _redeem
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSuccess(VoucherRedeemResult result) {
|
||||
final redemption = result.redemption;
|
||||
final isPending = redemption.status == VoucherRedemptionStatus.pending;
|
||||
final isFailed = redemption.status == VoucherRedemptionStatus.failed;
|
||||
|
||||
final (IconData icon, Color color, String title) = isPending
|
||||
? (
|
||||
Icons.hourglass_top_rounded,
|
||||
AppColor.warning,
|
||||
'Voucher sedang diproses',
|
||||
)
|
||||
: isFailed
|
||||
? (Icons.undo_rounded, AppColor.error, 'Penukaran gagal')
|
||||
: (Icons.check_circle, AppColor.success, 'Voucher berhasil ditukar');
|
||||
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
Icon(icon, color: color, size: 72),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.xl.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
redemption.voucherName,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (isPending)
|
||||
_Note(
|
||||
'EnakPoint sudah terpotong. Cek lagi di Voucher saya. Bila '
|
||||
'gagal, EnakPoint dikembalikan otomatis.',
|
||||
)
|
||||
else if (isFailed)
|
||||
_Note('EnakPoint sudah dikembalikan.')
|
||||
else if (redemption.code != null)
|
||||
VoucherCodeBox(code: redemption.code!)
|
||||
else
|
||||
const VoucherNoCodeNote(),
|
||||
const SizedBox(height: 16),
|
||||
if (!isPending && !isFailed && redemption.codeExpiresAt != null)
|
||||
Text(
|
||||
'Berlaku sampai '
|
||||
'${formatWibDateTime(redemption.codeExpiresAt!)}',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Saldo EnakPoint sekarang: '
|
||||
'${result.pointBalance.thousandFormat}',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: AppElevatedButton(
|
||||
title: 'Lihat voucher saya',
|
||||
onPressed: () => context.router.pop(true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Note extends StatelessWidget {
|
||||
const _Note(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(AppValue.borderRadius),
|
||||
),
|
||||
child: Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
const _InfoRow({required this.label, required this.value, this.valueColor});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final Color? valueColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.end,
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: valueColor ?? AppColor.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,108 +1,281 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_spinkit/flutter_spinkit.dart';
|
||||
|
||||
import '../../../application/voucher/voucher_loader/voucher_loader_bloc.dart';
|
||||
import '../../../application/voucher/voucher_redemption_loader/voucher_redemption_loader_bloc.dart';
|
||||
import '../../../application/wallet/wallet_loader/wallet_loader_bloc.dart';
|
||||
import '../../../common/extension/extension.dart';
|
||||
import '../../../common/theme/theme.dart';
|
||||
import '../../../sample/reward_sample_data.dart';
|
||||
import '../../components/assets/assets.gen.dart';
|
||||
import '../../components/delegate/filter_bar_delegate.dart';
|
||||
import '../../../domain/wallet/wallet.dart';
|
||||
import '../../../injection.dart';
|
||||
import '../../components/button/button.dart';
|
||||
import '../../components/toast/flushbar.dart';
|
||||
import '../../router/app_router.gr.dart';
|
||||
import 'reward_ui.dart';
|
||||
import 'widgets/reward_card.dart';
|
||||
import 'widgets/reward_detail_sheet.dart';
|
||||
|
||||
enum _RewardFilter {
|
||||
all('Semua'),
|
||||
active('Aktif'),
|
||||
used('Terpakai'),
|
||||
expired('Kedaluwarsa');
|
||||
|
||||
const _RewardFilter(this.label);
|
||||
|
||||
final String label;
|
||||
|
||||
bool matches(Reward reward) => switch (this) {
|
||||
_RewardFilter.all => true,
|
||||
_RewardFilter.active => reward.status == RewardStatus.active,
|
||||
_RewardFilter.used => reward.status == RewardStatus.used,
|
||||
_RewardFilter.expired => reward.status == RewardStatus.expired,
|
||||
};
|
||||
}
|
||||
import '../wallet/wallet_ui.dart';
|
||||
import 'widgets/redemption_card.dart';
|
||||
import 'widgets/redemption_detail_sheet.dart';
|
||||
import 'widgets/voucher_card.dart';
|
||||
|
||||
/// EnakReward: tukar EnakPoint ke voucher (§9.1) dan "Voucher saya" (§9.3).
|
||||
@RoutePage()
|
||||
class RewardPage extends StatefulWidget {
|
||||
class RewardPage extends StatefulWidget implements AutoRouteWrapper {
|
||||
const RewardPage({super.key});
|
||||
|
||||
@override
|
||||
State<RewardPage> createState() => _RewardPageState();
|
||||
|
||||
@override
|
||||
Widget wrappedRoute(BuildContext context) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (_) =>
|
||||
getIt<VoucherLoaderBloc>()..add(const VoucherLoaderEvent.fetched()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) =>
|
||||
getIt<VoucherRedemptionLoaderBloc>()
|
||||
..add(const VoucherRedemptionLoaderEvent.refreshed()),
|
||||
),
|
||||
],
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
|
||||
class _RewardPageState extends State<RewardPage> {
|
||||
_RewardFilter _filter = _RewardFilter.all;
|
||||
class _RewardPageState extends State<RewardPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController = TabController(
|
||||
length: 2,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
/// Aktif lebih dulu, urut yang paling cepat berakhir; sisanya terbaru dulu.
|
||||
final List<Reward> _rewards = [...rewardSamples]
|
||||
..sort((a, b) {
|
||||
final aActive = a.status == RewardStatus.active;
|
||||
final bActive = b.status == RewardStatus.active;
|
||||
if (aActive != bActive) return aActive ? -1 : 1;
|
||||
if (aActive) return a.validUntil.compareTo(b.validUntil);
|
||||
return b.redeemedAt.compareTo(a.redeemedAt);
|
||||
});
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Tombol Tukar nonaktif bila saldo EnakPoint kurang.
|
||||
context.read<WalletLoaderBloc>().add(const WalletLoaderEvent.fetched());
|
||||
}
|
||||
|
||||
List<Reward> get _activeRewards =>
|
||||
_rewards.where(_RewardFilter.active.matches).toList();
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int _countOf(_RewardFilter filter) => _rewards.where(filter.matches).length;
|
||||
/// Katalog, voucher saya, dan saldo dimuat ulang setiap kembali dari detail:
|
||||
/// stok bisa berubah, dan voucher yang habis harus hilang dari katalog.
|
||||
Future<void> _openDetail(Voucher voucher) async {
|
||||
final redeemed = await context.router.push<bool>(
|
||||
RewardDetailRoute(voucher: voucher),
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
context.read<VoucherLoaderBloc>().add(const VoucherLoaderEvent.fetched());
|
||||
context.read<VoucherRedemptionLoaderBloc>().add(
|
||||
const VoucherRedemptionLoaderEvent.refreshed(),
|
||||
);
|
||||
context.read<WalletLoaderBloc>().add(const WalletLoaderEvent.fetched());
|
||||
if (redeemed == true) _tabController.animateTo(1);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visible = _rewards.where(_filter.matches).toList();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColor.background,
|
||||
appBar: AppBar(title: const Text('Reward Saya')),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _buildSummary()),
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: FilterBarDelegate<_RewardFilter>(
|
||||
values: _RewardFilter.values,
|
||||
selected: _filter,
|
||||
labelOf: (filter) => filter.label,
|
||||
countOf: _countOf,
|
||||
onSelected: (filter) => setState(() => _filter = filter),
|
||||
),
|
||||
),
|
||||
if (visible.isEmpty)
|
||||
SliverFillRemaining(hasScrollBody: false, child: _buildEmpty())
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 32),
|
||||
sliver: SliverList.separated(
|
||||
itemCount: visible.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final reward = visible[index];
|
||||
return RewardCard(
|
||||
reward: reward,
|
||||
onTap: () => RewardDetailSheet.show(context, reward),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
appBar: AppBar(
|
||||
title: const Text('EnakReward'),
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(text: 'Tukar voucher'),
|
||||
Tab(text: 'Voucher saya'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_CatalogTab(onOpen: _openDetail),
|
||||
_RedemptionTab(onBrowse: () => _tabController.animateTo(0)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSummary() {
|
||||
final active = _activeRewards;
|
||||
// Sudah terurut: yang pertama adalah yang paling cepat berakhir.
|
||||
final expiringSoon = active.where(isRewardExpiringSoon).firstOrNull;
|
||||
class _CatalogTab extends StatelessWidget {
|
||||
const _CatalogTab({required this.onOpen});
|
||||
|
||||
final ValueChanged<Voucher> onOpen;
|
||||
|
||||
Future<void> _onRefresh(BuildContext context) async {
|
||||
context.read<WalletLoaderBloc>().add(const WalletLoaderEvent.fetched());
|
||||
final bloc = context.read<VoucherLoaderBloc>();
|
||||
bloc.add(const VoucherLoaderEvent.fetched());
|
||||
await bloc.stream.firstWhere((s) => !s.isFetching);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<VoucherLoaderBloc, VoucherLoaderState>(
|
||||
builder: (context, state) {
|
||||
if (!state.hasLoaded) {
|
||||
return state.failureOption.fold(
|
||||
() => const Center(
|
||||
child: SpinKitFadingCircle(color: AppColor.primary, size: 36),
|
||||
),
|
||||
(f) => _ErrorView(
|
||||
message: walletFailureMessage(f),
|
||||
onRetry: () => context.read<VoucherLoaderBloc>().add(
|
||||
const VoucherLoaderEvent.fetched(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return BlocBuilder<WalletLoaderBloc, WalletLoaderState>(
|
||||
builder: (context, walletState) => RefreshIndicator(
|
||||
color: AppColor.primary,
|
||||
onRefresh: () => _onRefresh(context),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
children: [
|
||||
_PointBalanceCard(walletState: walletState),
|
||||
const SizedBox(height: 16),
|
||||
if (state.vouchers.isEmpty)
|
||||
const _EmptyView(
|
||||
icon: Icons.confirmation_number_outlined,
|
||||
message: 'Belum ada voucher yang bisa ditukar',
|
||||
)
|
||||
else
|
||||
...state.vouchers.map(
|
||||
(voucher) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: VoucherCard(
|
||||
voucher: voucher,
|
||||
canAfford:
|
||||
!walletState.hasLoaded ||
|
||||
walletState.summary.pointBalance >=
|
||||
voucher.pointCost,
|
||||
onTap: () => onOpen(voucher),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RedemptionTab extends StatelessWidget {
|
||||
const _RedemptionTab({required this.onBrowse});
|
||||
|
||||
final VoidCallback onBrowse;
|
||||
|
||||
Future<void> _onRefresh(BuildContext context) async {
|
||||
final bloc = context.read<VoucherRedemptionLoaderBloc>();
|
||||
bloc.add(const VoucherRedemptionLoaderEvent.refreshed());
|
||||
await bloc.stream.firstWhere((s) => !s.isFetching);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<
|
||||
VoucherRedemptionLoaderBloc,
|
||||
VoucherRedemptionLoaderState
|
||||
>(
|
||||
listenWhen: (p, c) =>
|
||||
p.failureOption != c.failureOption && c.items.isNotEmpty,
|
||||
listener: (context, state) => state.failureOption.fold(
|
||||
() {},
|
||||
(f) => AppFlushbar.showError(context, walletFailureMessage(f)),
|
||||
),
|
||||
builder: (context, state) {
|
||||
if (!state.hasLoaded) {
|
||||
return state.failureOption.fold(
|
||||
() => const Center(
|
||||
child: SpinKitFadingCircle(color: AppColor.primary, size: 36),
|
||||
),
|
||||
(f) => _ErrorView(
|
||||
message: walletFailureMessage(f),
|
||||
onRetry: () => context.read<VoucherRedemptionLoaderBloc>().add(
|
||||
const VoucherRedemptionLoaderEvent.refreshed(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
color: AppColor.primary,
|
||||
onRefresh: () => _onRefresh(context),
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
final metrics = notification.metrics;
|
||||
if (metrics.pixels >= metrics.maxScrollExtent - 200) {
|
||||
context.read<VoucherRedemptionLoaderBloc>().add(
|
||||
const VoucherRedemptionLoaderEvent.loadedMore(),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: state.items.isEmpty
|
||||
? ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_EmptyView(
|
||||
icon: Icons.local_activity_outlined,
|
||||
message: 'Belum ada voucher',
|
||||
actionLabel: 'Tukar EnakPoint sekarang',
|
||||
onAction: onBrowse,
|
||||
),
|
||||
],
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
itemCount: state.items.length + (state.hasMore ? 1 : 0),
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= state.items.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: SpinKitFadingCircle(
|
||||
color: AppColor.primary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final redemption = state.items[index];
|
||||
return RedemptionCard(
|
||||
redemption: redemption,
|
||||
onTap: () =>
|
||||
RedemptionDetailSheet.show(context, redemption),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Saldo EnakPoint, selalu "setara potongan", tidak pernah "saldo Rp".
|
||||
class _PointBalanceCard extends StatelessWidget {
|
||||
const _PointBalanceCard({required this.walletState});
|
||||
|
||||
final WalletLoaderState walletState;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final summary = walletState.summary;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
@@ -111,183 +284,90 @@ class _RewardPageState extends State<RewardPage> {
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColor.primary.withOpacity(0.3),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
child: Row(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColor.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Assets.icons.enakrewardIcon.image(
|
||||
fit: BoxFit.contain,
|
||||
// Aset aslinya besar; decode seukuran tampilan saja.
|
||||
cacheWidth: (32 * MediaQuery.devicePixelRatioOf(context))
|
||||
.round(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Siap dipakai',
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textWhite.withOpacity(0.9),
|
||||
),
|
||||
),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'${active.length} reward',
|
||||
style: AppStyle.h5.copyWith(
|
||||
color: AppColor.textWhite,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Material(
|
||||
color: AppColor.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => context.router.push(const CoinRoute()),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
'Tukar Poin',
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (expiringSoon != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
Material(
|
||||
color: AppColor.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: () => RewardDetailSheet.show(context, expiringSoon),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.schedule,
|
||||
size: 16,
|
||||
color: AppColor.textWhite,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${expiringSoon.name} · '
|
||||
'${rewardValidityLabel(expiringSoon).toLowerCase()}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textWhite,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 18,
|
||||
color: AppColor.textWhite,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColor.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
],
|
||||
child: const WalletCurrencyIcon(
|
||||
currency: WalletCurrency.point,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'EnakPoint kamu',
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textWhite.withOpacity(0.9),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
walletState.hasLoaded
|
||||
? summary.pointBalance.thousandFormat
|
||||
: '…',
|
||||
style: AppStyle.h5.copyWith(
|
||||
color: AppColor.textWhite,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (walletState.hasLoaded)
|
||||
Text(
|
||||
pointDiscountLabel(summary.pointDiscountValue),
|
||||
style: AppStyle.xs.copyWith(
|
||||
color: AppColor.textWhite.withOpacity(0.9),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildEmpty() {
|
||||
final (String emoji, String message) = switch (_filter) {
|
||||
_RewardFilter.all => ('🎁', 'Belum ada reward'),
|
||||
_RewardFilter.active => ('🎯', 'Belum ada reward aktif'),
|
||||
_RewardFilter.used => ('✅', 'Belum ada reward yang dipakai'),
|
||||
_RewardFilter.expired => ('⏰', 'Tidak ada reward yang kedaluwarsa'),
|
||||
};
|
||||
class _EmptyView extends StatelessWidget {
|
||||
const _EmptyView({
|
||||
required this.icon,
|
||||
required this.message,
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String message;
|
||||
final String? actionLabel;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(32, 32, 32, 64),
|
||||
padding: const EdgeInsets.only(top: 48),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.primary.withOpacity(0.06),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(emoji, style: const TextStyle(fontSize: 44)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Icon(icon, size: 56, color: AppColor.textLight),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.lg.copyWith(fontWeight: FontWeight.w700),
|
||||
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Tukar poin kamu untuk mendapatkan reward menarik',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
if (_filter == _RewardFilter.all ||
|
||||
_filter == _RewardFilter.active) ...[
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.router.push(const CoinRoute()),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Tukar Poin Sekarang',
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (actionLabel != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
AppOutlineButton(
|
||||
title: actionLabel!,
|
||||
width: 220,
|
||||
onPressed: onAction,
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -295,3 +375,31 @@ class _RewardPageState extends State<RewardPage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorView extends StatelessWidget {
|
||||
const _ErrorView({required this.message, required this.onRetry});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(message, style: AppStyle.md, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
AppOutlineButton(
|
||||
title: 'Coba lagi',
|
||||
width: 160,
|
||||
onPressed: onRetry,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../common/extension/extension.dart';
|
||||
import '../../../../common/theme/theme.dart';
|
||||
import '../../../../domain/wallet/wallet.dart';
|
||||
import '../../wallet/wallet_ui.dart';
|
||||
import '../reward_ui.dart';
|
||||
import 'ticket_card.dart';
|
||||
|
||||
/// Satu voucher di "Voucher saya" (§9.3).
|
||||
class RedemptionCard extends StatelessWidget {
|
||||
const RedemptionCard({
|
||||
super.key,
|
||||
required this.redemption,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final VoucherRedemption redemption;
|
||||
final VoidCallback onTap;
|
||||
|
||||
bool get _isReady => redemption.status == VoucherRedemptionStatus.completed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = redemption.status;
|
||||
|
||||
return TicketCard(
|
||||
active: _isReady,
|
||||
onTap: onTap,
|
||||
stub: TicketStub(
|
||||
image: VoucherImage(
|
||||
url: redemption.voucherImageUrl,
|
||||
size: 44,
|
||||
dimmed: !_isReady,
|
||||
),
|
||||
pointCost: redemption.pointCost.thousandFormat,
|
||||
active: _isReady,
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
voucherTypeLabel(
|
||||
redemption.voucherType,
|
||||
redemption.faceValue,
|
||||
).toUpperCase(),
|
||||
style: AppStyle.xs.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (status != VoucherRedemptionStatus.unknown)
|
||||
VoucherRedemptionStatusBadge(status: status),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
redemption.voucherName,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppStyle.lg.copyWith(
|
||||
color: _isReady ? AppColor.textPrimary : AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (_isReady && redemption.code != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
redemption.code!,
|
||||
style: AppStyle.sm.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
_buildFootnote(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFootnote() {
|
||||
final (IconData icon, String text) = switch (redemption.status) {
|
||||
VoucherRedemptionStatus.completed => (
|
||||
Icons.schedule,
|
||||
redemption.codeExpiresAt == null
|
||||
? 'Tunjukkan ke kasir'
|
||||
: 'Berlaku sampai ${formatWibDate(redemption.codeExpiresAt!)}',
|
||||
),
|
||||
VoucherRedemptionStatus.pending => (
|
||||
Icons.hourglass_top_rounded,
|
||||
'Voucher sedang diproses',
|
||||
),
|
||||
VoucherRedemptionStatus.failed => (
|
||||
Icons.undo_rounded,
|
||||
'EnakPoint sudah dikembalikan',
|
||||
),
|
||||
VoucherRedemptionStatus.unknown => (
|
||||
Icons.info_outline,
|
||||
redemption.createdAt == null
|
||||
? ''
|
||||
: 'Ditukar ${formatWibDate(redemption.createdAt!)}',
|
||||
),
|
||||
};
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: AppColor.textSecondary),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppStyle.xs.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../common/extension/extension.dart';
|
||||
import '../../../../common/theme/theme.dart';
|
||||
import '../../../../domain/wallet/wallet.dart';
|
||||
import '../../wallet/wallet_ui.dart';
|
||||
import '../reward_ui.dart';
|
||||
|
||||
/// Detail voucher milik customer. Untuk memakai voucher, customer
|
||||
/// menunjukkan layar ini ke kasir; POS belum bisa menandai voucher terpakai.
|
||||
class RedemptionDetailSheet extends StatelessWidget {
|
||||
const RedemptionDetailSheet({super.key, required this.redemption});
|
||||
|
||||
final VoucherRedemption redemption;
|
||||
|
||||
static Future<void> show(BuildContext context, VoucherRedemption redemption) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppColor.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) => RedemptionDetailSheet(redemption: redemption),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final faceValue = voucherFaceValueLabel(
|
||||
redemption.voucherType,
|
||||
redemption.faceValue,
|
||||
);
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.85,
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.border,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildHeader(),
|
||||
const SizedBox(height: 20),
|
||||
_buildStatusContent(),
|
||||
const SizedBox(height: 20),
|
||||
if (redemption.createdAt != null)
|
||||
_InfoRow(
|
||||
label: 'Ditukar pada',
|
||||
value: formatWibDateTime(redemption.createdAt!),
|
||||
),
|
||||
if (redemption.status == VoucherRedemptionStatus.completed &&
|
||||
redemption.codeExpiresAt != null)
|
||||
_InfoRow(
|
||||
label: 'Berlaku sampai',
|
||||
value: formatWibDateTime(redemption.codeExpiresAt!),
|
||||
),
|
||||
_InfoRow(
|
||||
label: 'EnakPoint ditukar',
|
||||
value: redemption.pointCost.thousandFormat,
|
||||
),
|
||||
if (faceValue != null)
|
||||
_InfoRow(label: 'Nilai voucher', value: faceValue),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
final status = redemption.status;
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.primary.withOpacity(0.06),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: VoucherImage(url: redemption.voucherImageUrl, size: 40),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
voucherTypeLabel(
|
||||
redemption.voucherType,
|
||||
redemption.faceValue,
|
||||
).toUpperCase(),
|
||||
style: AppStyle.xs.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
redemption.voucherName,
|
||||
style: AppStyle.xl.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (status != VoucherRedemptionStatus.unknown) ...[
|
||||
const SizedBox(width: 8),
|
||||
VoucherRedemptionStatusBadge(status: status),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusContent() {
|
||||
switch (redemption.status) {
|
||||
case VoucherRedemptionStatus.completed:
|
||||
return redemption.code == null
|
||||
? const VoucherNoCodeNote()
|
||||
: VoucherCodeBox(code: redemption.code!);
|
||||
case VoucherRedemptionStatus.pending:
|
||||
return const _Note(
|
||||
icon: Icons.hourglass_top_rounded,
|
||||
color: AppColor.warning,
|
||||
message:
|
||||
'Voucher sedang diproses. EnakPoint sudah terpotong; cek lagi '
|
||||
'nanti.',
|
||||
);
|
||||
case VoucherRedemptionStatus.failed:
|
||||
return const _Note(
|
||||
icon: Icons.undo_rounded,
|
||||
color: AppColor.error,
|
||||
message: 'Gagal, EnakPoint sudah dikembalikan.',
|
||||
);
|
||||
case VoucherRedemptionStatus.unknown:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Note extends StatelessWidget {
|
||||
const _Note({required this.icon, required this.color, required this.message});
|
||||
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.background,
|
||||
borderRadius: BorderRadius.circular(AppValue.borderRadius),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: color),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(value, style: AppStyle.sm.copyWith(fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../common/extension/extension.dart';
|
||||
import '../../../../common/theme/theme.dart';
|
||||
import '../../../../sample/reward_sample_data.dart';
|
||||
import '../reward_ui.dart';
|
||||
|
||||
/// Kartu reward berbentuk tiket: sobekan kiri (emoji + poin) dan isi kanan.
|
||||
class RewardCard extends StatelessWidget {
|
||||
const RewardCard({super.key, required this.reward, required this.onTap});
|
||||
|
||||
final Reward reward;
|
||||
final VoidCallback onTap;
|
||||
|
||||
static const double _stubWidth = 84;
|
||||
|
||||
bool get _isActive => reward.status == RewardStatus.active;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PhysicalShape(
|
||||
clipper: const _TicketClipper(notchX: _stubWidth + 0.5),
|
||||
color: AppColor.white,
|
||||
elevation: 2,
|
||||
shadowColor: Colors.black.withOpacity(0.25),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildStub(),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 14),
|
||||
child: CustomPaint(
|
||||
size: Size(1, double.infinity),
|
||||
painter: _VerticalDashPainter(),
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildBody()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStub() {
|
||||
return Container(
|
||||
width: _stubWidth,
|
||||
color: _isActive
|
||||
? AppColor.primary.withOpacity(0.06)
|
||||
: AppColor.background,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
RewardEmoji(reward: reward, size: 34),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
reward.pointsUsed.thousandFormat,
|
||||
style: AppStyle.md.copyWith(
|
||||
color: _isActive ? AppColor.primary : AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'poin',
|
||||
style: AppStyle.xs.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
reward.categoryName.toUpperCase(),
|
||||
style: AppStyle.xs.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
RewardStatusBadge(status: reward.status),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
reward.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppStyle.lg.copyWith(
|
||||
color: _isActive ? AppColor.textPrimary : AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
reward.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildFootnote()),
|
||||
if (_isActive) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.primary,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'Pakai',
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFootnote() {
|
||||
final (IconData icon, String text, Color color) = switch (reward.status) {
|
||||
RewardStatus.active => (
|
||||
Icons.schedule,
|
||||
rewardValidityLabel(reward),
|
||||
isRewardExpiringSoon(reward)
|
||||
? AppColor.warning
|
||||
: AppColor.textSecondary,
|
||||
),
|
||||
RewardStatus.used => (
|
||||
Icons.check_circle_outline,
|
||||
reward.usedAt == null
|
||||
? 'Sudah dipakai'
|
||||
: 'Dipakai ${formatRewardDate(reward.usedAt!)}',
|
||||
AppColor.textSecondary,
|
||||
),
|
||||
RewardStatus.expired => (
|
||||
Icons.event_busy_outlined,
|
||||
'Berakhir ${formatRewardDate(reward.validUntil)}',
|
||||
AppColor.textSecondary,
|
||||
),
|
||||
};
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppStyle.xs.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persegi panjang membulat dengan lekuk setengah lingkaran di atas dan
|
||||
/// bawah pada posisi [notchX], tempat garis sobekan tiket.
|
||||
class _TicketClipper extends CustomClipper<Path> {
|
||||
const _TicketClipper({required this.notchX});
|
||||
|
||||
final double notchX;
|
||||
|
||||
static const double _radius = 16;
|
||||
static const double _notchRadius = 8;
|
||||
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
final card = Path()
|
||||
..addRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Offset.zero & size,
|
||||
const Radius.circular(_radius),
|
||||
),
|
||||
);
|
||||
final notches = Path()
|
||||
..addOval(
|
||||
Rect.fromCircle(center: Offset(notchX, 0), radius: _notchRadius),
|
||||
)
|
||||
..addOval(
|
||||
Rect.fromCircle(
|
||||
center: Offset(notchX, size.height),
|
||||
radius: _notchRadius,
|
||||
),
|
||||
);
|
||||
return Path.combine(PathOperation.difference, card, notches);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(covariant _TicketClipper oldClipper) =>
|
||||
oldClipper.notchX != notchX;
|
||||
}
|
||||
|
||||
class _VerticalDashPainter extends CustomPainter {
|
||||
const _VerticalDashPainter();
|
||||
|
||||
static const double _dash = 5;
|
||||
static const double _gap = 4;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = AppColor.border
|
||||
..strokeWidth = size.width;
|
||||
final x = size.width / 2;
|
||||
|
||||
for (double y = 0; y < size.height; y += _dash + _gap) {
|
||||
final end = (y + _dash).clamp(0, size.height).toDouble();
|
||||
canvas.drawLine(Offset(x, y), Offset(x, end), paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
import '../../../../common/extension/extension.dart';
|
||||
import '../../../../common/theme/theme.dart';
|
||||
import '../../../../common/ui/painter/dashed_line_painter.dart';
|
||||
import '../../../../sample/reward_sample_data.dart';
|
||||
import '../../../components/toast/flushbar.dart';
|
||||
import '../reward_ui.dart';
|
||||
|
||||
class RewardDetailSheet extends StatelessWidget {
|
||||
const RewardDetailSheet({super.key, required this.reward});
|
||||
|
||||
final Reward reward;
|
||||
|
||||
static Future<void> show(BuildContext context, Reward reward) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppColor.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) => RewardDetailSheet(reward: reward),
|
||||
);
|
||||
}
|
||||
|
||||
bool get _isActive => reward.status == RewardStatus.active;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.85,
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.border,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildHeader(),
|
||||
const SizedBox(height: 20),
|
||||
if (_isActive && reward.couponCode != null)
|
||||
_CouponBox(code: reward.couponCode!)
|
||||
else
|
||||
_buildInactiveNote(),
|
||||
const SizedBox(height: 20),
|
||||
_InfoRow(
|
||||
label: 'Ditukar pada',
|
||||
value: formatRewardDate(reward.redeemedAt),
|
||||
),
|
||||
_InfoRow(
|
||||
label: 'Berlaku sampai',
|
||||
value: formatRewardDate(reward.validUntil),
|
||||
valueColor: isRewardExpiringSoon(reward)
|
||||
? AppColor.warning
|
||||
: null,
|
||||
),
|
||||
if (reward.usedAt != null)
|
||||
_InfoRow(
|
||||
label: 'Dipakai pada',
|
||||
value: formatRewardDate(reward.usedAt!),
|
||||
),
|
||||
_InfoRow(
|
||||
label: 'Poin ditukar',
|
||||
value: '${reward.pointsUsed.thousandFormat} poin',
|
||||
),
|
||||
if (reward.termsAndConditions != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Syarat & Ketentuan',
|
||||
style: AppStyle.md.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
reward.termsAndConditions!,
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _isActive
|
||||
? AppColor.primary.withOpacity(0.06)
|
||||
: AppColor.background,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: RewardEmoji(reward: reward, size: 28),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
reward.categoryName.toUpperCase(),
|
||||
style: AppStyle.xs.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
reward.name,
|
||||
style: AppStyle.xl.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
Text(
|
||||
reward.description,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
RewardStatusBadge(status: reward.status),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInactiveNote() {
|
||||
final message = switch (reward.status) {
|
||||
RewardStatus.used =>
|
||||
reward.usedAt == null
|
||||
? 'Reward ini sudah dipakai.'
|
||||
: 'Reward ini sudah dipakai pada '
|
||||
'${formatRewardDate(reward.usedAt!)}.',
|
||||
_ =>
|
||||
'Masa berlaku reward ini sudah habis pada '
|
||||
'${formatRewardDate(reward.validUntil)}.',
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.background,
|
||||
borderRadius: BorderRadius.circular(AppValue.borderRadius),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 20, color: reward.status.color),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CouponBox extends StatelessWidget {
|
||||
const _CouponBox({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 reward disalin');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColor.border),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
QrImageView(
|
||||
data: code,
|
||||
size: 168,
|
||||
padding: EdgeInsets.zero,
|
||||
backgroundColor: AppColor.white,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Tunjukkan QR atau kode ini ke kasir',
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
CustomPaint(
|
||||
size: const Size(double.infinity, 1),
|
||||
painter: DashedLinePainter(),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
code,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
const _InfoRow({required this.label, required this.value, this.valueColor});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final Color? valueColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
value,
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: valueColor ?? AppColor.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../common/theme/theme.dart';
|
||||
|
||||
/// Kartu berbentuk tiket: sobekan kiri ([stub]) dan isi kanan ([body]),
|
||||
/// dipisah garis putus-putus.
|
||||
class TicketCard extends StatelessWidget {
|
||||
const TicketCard({
|
||||
super.key,
|
||||
required this.stub,
|
||||
required this.body,
|
||||
required this.onTap,
|
||||
this.active = true,
|
||||
});
|
||||
|
||||
final Widget stub;
|
||||
final Widget body;
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Latar sobekan berwarna untuk voucher yang bisa ditukar / dipakai.
|
||||
final bool active;
|
||||
|
||||
static const double stubWidth = 84;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PhysicalShape(
|
||||
clipper: const _TicketClipper(notchX: stubWidth + 0.5),
|
||||
color: AppColor.white,
|
||||
elevation: 2,
|
||||
shadowColor: Colors.black.withOpacity(0.25),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
width: stubWidth,
|
||||
color: active
|
||||
? AppColor.primary.withOpacity(0.06)
|
||||
: AppColor.background,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 16,
|
||||
),
|
||||
child: stub,
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 14),
|
||||
child: CustomPaint(
|
||||
size: Size(1, double.infinity),
|
||||
painter: _VerticalDashPainter(),
|
||||
),
|
||||
),
|
||||
Expanded(child: body),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persegi panjang membulat dengan lekuk setengah lingkaran di atas dan
|
||||
/// bawah pada posisi [notchX], tempat garis sobekan tiket.
|
||||
class _TicketClipper extends CustomClipper<Path> {
|
||||
const _TicketClipper({required this.notchX});
|
||||
|
||||
final double notchX;
|
||||
|
||||
static const double _radius = 16;
|
||||
static const double _notchRadius = 8;
|
||||
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
final card = Path()
|
||||
..addRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Offset.zero & size,
|
||||
const Radius.circular(_radius),
|
||||
),
|
||||
);
|
||||
final notches = Path()
|
||||
..addOval(
|
||||
Rect.fromCircle(center: Offset(notchX, 0), radius: _notchRadius),
|
||||
)
|
||||
..addOval(
|
||||
Rect.fromCircle(
|
||||
center: Offset(notchX, size.height),
|
||||
radius: _notchRadius,
|
||||
),
|
||||
);
|
||||
return Path.combine(PathOperation.difference, card, notches);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(covariant _TicketClipper oldClipper) =>
|
||||
oldClipper.notchX != notchX;
|
||||
}
|
||||
|
||||
class _VerticalDashPainter extends CustomPainter {
|
||||
const _VerticalDashPainter();
|
||||
|
||||
static const double _dash = 5;
|
||||
static const double _gap = 4;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = AppColor.border
|
||||
..strokeWidth = size.width;
|
||||
final x = size.width / 2;
|
||||
|
||||
for (double y = 0; y < size.height; y += _dash + _gap) {
|
||||
final end = (y + _dash).clamp(0, size.height).toDouble();
|
||||
canvas.drawLine(Offset(x, y), Offset(x, end), paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
/// Isi sobekan: gambar voucher dan EnakPoint yang ditukar.
|
||||
class TicketStub extends StatelessWidget {
|
||||
const TicketStub({
|
||||
super.key,
|
||||
required this.image,
|
||||
required this.pointCost,
|
||||
this.active = true,
|
||||
});
|
||||
|
||||
final Widget image;
|
||||
final String pointCost;
|
||||
final bool active;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
image,
|
||||
const SizedBox(height: 8),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
pointCost,
|
||||
style: AppStyle.md.copyWith(
|
||||
color: active ? AppColor.primary : AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'EnakPoint',
|
||||
style: AppStyle.xs.copyWith(
|
||||
fontSize: 10,
|
||||
color: AppColor.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../common/extension/extension.dart';
|
||||
import '../../../../common/theme/theme.dart';
|
||||
import '../../../../domain/wallet/wallet.dart';
|
||||
import '../../wallet/wallet_ui.dart';
|
||||
import '../reward_ui.dart';
|
||||
import 'ticket_card.dart';
|
||||
|
||||
/// Voucher di katalog (§9.1).
|
||||
class VoucherCard extends StatelessWidget {
|
||||
const VoucherCard({
|
||||
super.key,
|
||||
required this.voucher,
|
||||
required this.canAfford,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final Voucher voucher;
|
||||
|
||||
/// Saldo EnakPoint cukup untuk [Voucher.pointCost].
|
||||
final bool canAfford;
|
||||
final VoidCallback onTap;
|
||||
|
||||
bool get _canRedeem => canAfford && !voucher.isOutOfStock;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final faceValue = voucherFaceValueLabel(voucher.type, voucher.faceValue);
|
||||
|
||||
return TicketCard(
|
||||
active: _canRedeem,
|
||||
onTap: onTap,
|
||||
stub: TicketStub(
|
||||
image: VoucherImage(
|
||||
url: voucher.imageUrl,
|
||||
size: 44,
|
||||
dimmed: voucher.isOutOfStock,
|
||||
),
|
||||
pointCost: voucher.pointCost.thousandFormat,
|
||||
active: _canRedeem,
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
voucherTypeLabel(voucher.type, voucher.faceValue).toUpperCase(),
|
||||
style: AppStyle.xs.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
voucher.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppStyle.lg.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
if (faceValue != null)
|
||||
Text(
|
||||
faceValue,
|
||||
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildFootnote()),
|
||||
const SizedBox(width: 8),
|
||||
_buildAction(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFootnote() {
|
||||
final parts = [
|
||||
if (voucher.validUntil != null)
|
||||
's/d ${formatWibDate(voucher.validUntil!)}',
|
||||
if (voucher.available != null && !voucher.isOutOfStock)
|
||||
'sisa ${voucher.available!.thousandFormat}',
|
||||
];
|
||||
if (parts.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Text(
|
||||
parts.join(' · '),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppStyle.xs.copyWith(
|
||||
color: AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAction() {
|
||||
final (String label, bool enabled) = voucher.isOutOfStock
|
||||
? ('Habis', false)
|
||||
: canAfford
|
||||
? ('Tukar', true)
|
||||
: ('Poin kurang', false);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? AppColor.primary : AppColor.background,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: AppStyle.sm.copyWith(
|
||||
color: enabled ? AppColor.white : AppColor.textSecondary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user