Compare commits

..
9 Commits
Author SHA1 Message Date
efrilm 84cae9e789 feat: build number
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s
2026-10-10 02:33:37 +07:00
efrilm 38850b281f feat: integrate enakgame and voucher
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s
2026-10-10 02:33:16 +07:00
efrilm e5515d5871 feat: update carousel image
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s
2026-10-01 18:25:34 +07:00
efrilm cb5e5e70bf fix: login field
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s
2026-10-01 16:59:57 +07:00
efrilm 671dd3a5d0 update
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s
2026-10-01 11:31:59 +07:00
aefril 66d59ee59e Merge pull request 'Dev' (#3) from dev into main
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s
Reviewed-on: https://gits.altru.id/aefril/enaklo-flutter/pulls/3
2026-10-01 06:09:09 +02:00
efrilm 57fb56303a update 2026-10-01 10:59:03 +07:00
aefril 2560c1890b Merge pull request 'dev' (#2) from dev into main
Reviewed-on: https://gits.altru.id/aefril/enaklo-flutter/pulls/2
2026-01-18 16:03:39 +00:00
aefril a09fda0585 Merge pull request 'dev' (#1) from dev into main
Reviewed-on: https://gits.altru.id/aefril/enaklo-flutter/pulls/1
2025-09-18 08:07:46 +00:00
112 changed files with 7178 additions and 8071 deletions
+1 -1
View File
@@ -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
+7
View File
@@ -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
+799
View File
@@ -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.
+1 -1
View File
@@ -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));
},
);
}
}
@@ -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),
);
},
);
}
}
@@ -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
@@ -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;
}
@@ -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()]);
}
}
+2 -1
View File
@@ -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);
}
-148
View File
@@ -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 -5
View File
@@ -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';
}
-35
View File
@@ -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;
}
-12
View File
@@ -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;
}
+69 -18
View File
@@ -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,
});
+2 -1
View File
@@ -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';
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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));
}
}
}
-31
View File
@@ -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);
}
-10
View File
@@ -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
-90
View File
@@ -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,
+1 -1
View File
@@ -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
+65 -18
View File
@@ -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
View File
@@ -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,
),
),
],
),
),
);
}
}
+6 -6
View File
@@ -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,
),
),
),
],
),
);
}
}
+346 -238
View File
@@ -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,
),
],
),
),
);
}
}
+208 -51
View File
@@ -1,60 +1,50 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:flutter/services.dart';
import '../../../common/extension/extension.dart';
import '../../../common/theme/theme.dart';
import '../../../sample/reward_sample_data.dart';
import '../../../common/ui/painter/dashed_line_painter.dart';
import '../../../domain/wallet/wallet.dart';
import '../../components/toast/flushbar.dart';
import '../wallet/wallet_ui.dart';
/// Reward aktif dengan sisa masa berlaku segini dianggap segera berakhir.
const int rewardExpiringSoonDays = 7;
/// Label jenis voucher (§9.1).
String voucherTypeLabel(VoucherType type, int faceValue) => switch (type) {
VoucherType.fixedValue => 'Potongan ${faceValue.currencyFormatRp}',
VoucherType.percentage => 'Potongan persen',
VoucherType.freeItem => 'Gratis item',
VoucherType.merchantBenefit => 'Benefit merchant',
VoucherType.unknown => 'Voucher',
};
extension RewardStatusUi on RewardStatus {
/// "senilai Rp 20.000". `null` untuk potongan nominal, karena nilainya sudah
/// ada di [voucherTypeLabel], dan bila server tidak mengirim nilai.
String? voucherFaceValueLabel(VoucherType type, int faceValue) =>
type == VoucherType.fixedValue || faceValue <= 0
? null
: 'senilai ${faceValue.currencyFormatRp}';
extension VoucherRedemptionStatusUi on VoucherRedemptionStatus {
String get label => switch (this) {
RewardStatus.active => 'Aktif',
RewardStatus.used => 'Terpakai',
RewardStatus.expired => 'Kedaluwarsa',
VoucherRedemptionStatus.completed => 'Siap dipakai',
VoucherRedemptionStatus.pending => 'Sedang diproses',
VoucherRedemptionStatus.failed => 'Gagal',
VoucherRedemptionStatus.unknown => '',
};
Color get color => switch (this) {
RewardStatus.active => AppColor.success,
RewardStatus.used => AppColor.textSecondary,
RewardStatus.expired => AppColor.error,
VoucherRedemptionStatus.completed => AppColor.success,
VoucherRedemptionStatus.pending => AppColor.warning,
VoucherRedemptionStatus.failed => AppColor.error,
VoucherRedemptionStatus.unknown => AppColor.textSecondary,
};
}
/// Tanggal pendek: "1 Okt 2026".
String formatRewardDate(DateTime date) =>
DateFormat('d MMM yyyy', 'id_ID').format(date);
class VoucherRedemptionStatusBadge extends StatelessWidget {
const VoucherRedemptionStatusBadge({super.key, required this.status});
/// Sisa hari kalender sampai [Reward.validUntil]; 0 berarti berakhir hari ini.
int rewardDaysLeft(Reward reward) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final end = DateTime(
reward.validUntil.year,
reward.validUntil.month,
reward.validUntil.day,
);
return end.difference(today).inDays;
}
bool isRewardExpiringSoon(Reward reward) =>
reward.status == RewardStatus.active &&
rewardDaysLeft(reward) <= rewardExpiringSoonDays;
/// "Berakhir hari ini" / "Berakhir 3 hari lagi" / "Berlaku s/d 25 Okt 2026".
String rewardValidityLabel(Reward reward) {
final daysLeft = rewardDaysLeft(reward);
if (daysLeft <= 0) return 'Berakhir hari ini';
if (daysLeft <= rewardExpiringSoonDays) {
return 'Berakhir $daysLeft hari lagi';
}
return 'Berlaku s/d ${formatRewardDate(reward.validUntil)}';
}
class RewardStatusBadge extends StatelessWidget {
const RewardStatusBadge({super.key, required this.status});
final RewardStatus status;
final VoucherRedemptionStatus status;
@override
Widget build(BuildContext context) {
@@ -75,13 +65,62 @@ class RewardStatusBadge extends StatelessWidget {
}
}
/// Emoji reward; abu-abu untuk reward yang sudah tidak bisa dipakai.
class RewardEmoji extends StatelessWidget {
const RewardEmoji({super.key, required this.reward, required this.size});
/// Pesan untuk penolakan tukar voucher (§9.2). `cause` dari server berbahasa
/// Inggris dan tidak ditampilkan mentah. [reloadCatalog] menandai voucher yang
/// sudah tidak tersedia, sehingga katalog perlu dimuat ulang.
({String message, bool reloadCatalog}) describeVoucherRedeemFailure(
WalletFailure failure,
) {
final cause = failure.maybeMap(
rejected: (value) => value.cause.toLowerCase(),
orElse: () => null,
);
if (cause == null) {
return (message: walletFailureMessage(failure), reloadCatalog: false);
}
final Reward reward;
if (cause.contains('not enough enakpoint')) {
return (message: 'EnakPoint kamu kurang.', reloadCatalog: false);
}
if (cause.contains('out of stock')) {
return (message: 'Voucher sudah habis.', reloadCatalog: true);
}
if (cause.contains('per customer')) {
return (
message: 'Kamu sudah mencapai batas penukaran voucher ini.',
reloadCatalog: false,
);
}
if (cause.contains('customer is not active')) {
return (message: 'Akun tidak aktif.', reloadCatalog: false);
}
if (cause.contains('not available') ||
cause.contains('cannot be redeemed yet') ||
cause.contains('has ended')) {
return (message: 'Voucher tidak tersedia.', reloadCatalog: true);
}
// Termasuk Idempotency-Key yang terpakai untuk voucher lain (bug di app).
return (
message: 'Penukaran voucher tidak bisa diproses.',
reloadCatalog: false,
);
}
/// Gambar voucher dari `image_url`; ikon tiket bila kosong.
class VoucherImage extends StatelessWidget {
const VoucherImage({
super.key,
required this.url,
required this.size,
this.dimmed = false,
});
final String? url;
final double size;
/// Abu-abu untuk voucher yang tidak bisa ditukar / dipakai.
final bool dimmed;
static const ColorFilter _greyscale = ColorFilter.matrix([
0.2126, 0.7152, 0.0722, 0, 0, //
0.2126, 0.7152, 0.0722, 0, 0, //
@@ -91,8 +130,126 @@ class RewardEmoji extends StatelessWidget {
@override
Widget build(BuildContext context) {
final emoji = Text(reward.emoji, style: TextStyle(fontSize: size));
if (reward.status == RewardStatus.active) return emoji;
return ColorFiltered(colorFilter: _greyscale, child: emoji);
final fallback = SizedBox(
width: size,
height: size,
child: Icon(
Icons.confirmation_number_outlined,
size: size * 0.7,
color: dimmed ? AppColor.textLight : AppColor.primary,
),
);
final image = url == null
? fallback
: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: url!,
width: size,
height: size,
fit: BoxFit.cover,
placeholder: (_, _) => SizedBox(width: size, height: size),
errorWidget: (_, _, _) => fallback,
),
);
if (!dimmed || url == null) return image;
return ColorFiltered(colorFilter: _greyscale, child: image);
}
}
/// Kode voucher yang bisa disalin. Customer menunjukkan layar ini ke kasir.
class VoucherCodeBox extends StatelessWidget {
const VoucherCodeBox({super.key, required this.code});
final String code;
Future<void> _copy(BuildContext context) async {
await Clipboard.setData(ClipboardData(text: code));
if (!context.mounted) return;
AppFlushbar.showSuccess(context, 'Kode voucher disalin');
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 8, 8),
decoration: BoxDecoration(
border: Border.all(color: AppColor.border),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Text(
'Tunjukkan kode ini ke kasir',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
const SizedBox(height: 10),
CustomPaint(
size: const Size(double.infinity, 1),
painter: DashedLinePainter(),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: SelectableText(
code,
textAlign: TextAlign.center,
style: AppStyle.xl.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 2,
),
),
),
IconButton(
onPressed: () => _copy(context),
tooltip: 'Salin kode',
icon: const Icon(
Icons.copy_rounded,
size: 20,
color: AppColor.primary,
),
),
],
),
],
),
);
}
}
/// Catatan untuk voucher tanpa kode (`code` null): customer menunjukkan layar
/// voucher ke kasir.
class VoucherNoCodeNote extends StatelessWidget {
const VoucherNoCodeNote({super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColor.successWithOpacity(0.08),
borderRadius: BorderRadius.circular(AppValue.borderRadius),
),
child: Row(
children: [
const Icon(
Icons.storefront_outlined,
size: 20,
color: AppColor.success,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Voucher ini tidak memakai kode. Tunjukkan layar ini ke kasir.',
style: AppStyle.sm,
),
),
],
),
);
}
}
@@ -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