From cd3904caca100ba25cad50b7afddc620a5889c01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 21:21:42 +0200 Subject: [PATCH 01/75] docs(spec): design pro DCA sell extension Opt-in trading mode rozsirujici DCA plany o limitni prodejni prikazy, P&L tracking a volitelny cil zisku. Globalni settings gate + per-plan allowSells flag. MVP support Coinmate + Binance. Co-Authored-By: Claude Opus 4.7 --- .../2026-04-23-dca-sell-extension-design.md | 488 ++++++++++++++++++ 1 file changed, 488 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-23-dca-sell-extension-design.md diff --git a/docs/superpowers/specs/2026-04-23-dca-sell-extension-design.md b/docs/superpowers/specs/2026-04-23-dca-sell-extension-design.md new file mode 100644 index 0000000..4c30dc4 --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-dca-sell-extension-design.md @@ -0,0 +1,488 @@ +# DCA Sell Extension - Design Spec + +**Datum:** 2026-04-23 +**Status:** Draft - waiting user review +**Scope:** Android app (`accbot-android/`) + +## 1. Cíl a kontext + +Rozšířit existující DCA plány o opt-in **trading mode** - schopnost zadávat limitní prodejní příkazy na burzu, sledovat jejich stav, zobrazovat sell transakce v historii a grafu, a počítat realizovaný i nerealizovaný P&L vůči volitelnému cíli zisku. + +Funkce je primárně určena pro pokročilé uživatele, kteří kromě akumulace občas realizují část pozice. Skrývá se za globální Settings toggle a per-plán opt-in - běžný DCA uživatel nezažije žádnou změnu. + +### Mimo scope MVP + +- Auto-sell triggery (plán prodá sám při dosažení ceny) +- Online price watcher +- Stop-loss +- Ladder sells (více limit orderů najednou) +- Sell wizard s doporučenou cenou / profit preview kalkulátorem nad rámec quick-select chipů +- Limit BUY (DCA zůstává market buy) +- Loan tracking (Firefish nebo jiné půjčky) +- Push notifikace o filled orderech + +## 2. Architektura + +``` +┌──────────────────────────────────────────────────────────┐ +│ Plan Detail Screen │ +│ ┌──────────────────┐ ┌─────────────────────────────┐ │ +│ │ Buy side │ │ Sell side (opt-in) │ │ +│ │ - DCA schedule │ │ - "Place limit sell" button │ │ +│ │ - Next buy │ │ - Open orders list │ │ +│ │ - Buy history │ │ - P&L card + target progress│ │ +│ └──────────────────┘ └─────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ + │ │ + ▼ ▼ + ┌─────────────┐ ┌──────────────────┐ + │ DcaWorker │ │ ExchangeApi │ + │ (buy exec) │ │ - marketBuy() │ + └─────────────┘ │ - limitSell() │ ← nový + │ │ - getOrderStatus│ ← refactor + │ │ - cancelOrder() │ ← nový + ▼ └──────────────────┘ + ┌──────────────────────────────────────┐ + │ ResolvePendingTransactionsUseCase │ ← rozšíří se + │ (řeší PENDING + PARTIAL na BUY+SELL)│ + └──────────────────────────────────────┘ + ▲ + │ triggers + ├── App onResume + ├── DcaWorker tick (buy exec) + ├── Po placement / cancel + ├── Pull-to-refresh + └── SellPollingWorker (opt-in) +``` + +### Klíčové principy + +1. **Minimum invasive na existující kód.** Buy-side logika beze změn. Přidají se 2 pole na `DcaPlan`, 3 pole na `TransactionEntity`, 3 nové metody v `ExchangeApi`. +2. **Znovuužití existujícího pending-tx flow.** Limit sell = transakce s `side=SELL`, `status=PENDING`. Existující `ResolvePendingTransactionsUseCase` řeší fill resolution pro buy i sell. +3. **Dvouvrstvý opt-in.** Globální Settings toggle (default OFF) → per-plán `allowSells` flag. Bez globálního toggle žádné nové UI nikde. +4. **Polling, ne websocket.** Žádné real-time updaty. Triggery: app open, DCA worker tick, po user akci, pull-to-refresh, opt-in periodic worker. + +## 3. Datový model + +### UserPreferences (nové flagy) + +```kotlin +fun isTradingEnabled(): Boolean // default false (master gate) +fun setTradingEnabled(enabled: Boolean) + +fun isPeriodicSellPollingEnabled(): Boolean // default false +fun getSellPollingFrequency(): DcaFrequency // default HOURLY +fun getSellPollingCronExpression(): String? // jen pro CUSTOM +fun getSellPollingScheduleConfig(): String? // serialized ScheduleBuilderState +fun setPeriodicSellPolling(enabled: Boolean, frequency: DcaFrequency, ...) +``` + +SharedPreferences keys: `trading_enabled`, `sell_polling_enabled`, `sell_polling_frequency`, `sell_polling_cron`, `sell_polling_schedule_config`. Per-device, ne v backupu (advanced opt-in se přijatelně re-enabluje po restore). + +### DcaPlan (rozšíření) + +```kotlin +data class DcaPlan( + // existující pole beze změn + val allowSells: Boolean = false, + val targetProfitAmount: BigDecimal? = null // jednotka = plan.fiat +) +``` + +### TransactionEntity (rozšíření) + +```kotlin +data class TransactionEntity( + // existující pole beze změn + val side: TransactionSide = TransactionSide.BUY, + val limitPrice: BigDecimal? = null, + val requestedCryptoAmount: BigDecimal? = null +) + +enum class TransactionSide { BUY, SELL } +``` + +**Sémantika polí pro různé stavy:** + +| Pole | BUY market | SELL limit | +|---|---|---| +| `cryptoAmount` | filled (final) | filled so far (0 → requested) | +| `fiatAmount` | spent (final) | received so far (0 → requested × avg fill) | +| `requestedCryptoAmount` | `null` | `0.01` (zadáno při založení, fixed) | +| `limitPrice` | `null` | `1 200 000` (fixed) | + +Progress fill v UI = `cryptoAmount / requestedCryptoAmount`. + +### Lifecycle limit sell orderu + +| Fáze | status | cryptoAmount | fiatAmount | +|---|---|---|---| +| Order zadán | PENDING | 0 | 0 | +| Partially filled | PARTIAL | 0.005 | 6 000 | +| Fully filled | COMPLETED | 0.01 (= requested) | 12 000 | +| Canceled bez fillu | FAILED | 0 | 0 | +| Canceled po partial fillu | PARTIAL | filled-so-far | filled-so-far | +| Expired bez fillu | FAILED | 0 | 0 | +| Expired po partial fillu | PARTIAL | filled-so-far | filled-so-far | + +`requestedCryptoAmount` zůstává fixní napříč všemi stavy. + +### Room migrace v19 → v20 + +```sql +ALTER TABLE dca_plans ADD COLUMN allowSells INTEGER NOT NULL DEFAULT 0; +ALTER TABLE dca_plans ADD COLUMN targetProfitAmount TEXT DEFAULT NULL; + +ALTER TABLE transactions ADD COLUMN side TEXT NOT NULL DEFAULT 'BUY'; +ALTER TABLE transactions ADD COLUMN limitPrice TEXT DEFAULT NULL; +ALTER TABLE transactions ADD COLUMN requestedCryptoAmount TEXT DEFAULT NULL; + +CREATE INDEX IF NOT EXISTS idx_tx_plan_side_status + ON transactions(planId, side, status); +``` + +Vše backward-kompatibilní. + +### Backup / Restore + +`BackupPlan` rozšířen o `allowSells` + `targetProfitAmount`. `BackupTransaction` rozšířen o `side` + `limitPrice` + `requestedCryptoAmount`. Všechna nová pole nepovinná s defaulty (BUY, null) pro starší verze. + +### P&L (derivovaný, neperzistuje se) + +```kotlin +data class PlanPnL( + val totalBoughtFiat: BigDecimal, + val totalBoughtCrypto: BigDecimal, + val totalSoldFiat: BigDecimal, + val totalSoldCrypto: BigDecimal, + val currentCryptoHeld: BigDecimal, // bought - sold + val avgBuyPrice: BigDecimal?, // null pokud nic nenakoupeno + val currentValueFiat: BigDecimal?, // null pokud spot není dostupný + val realizedPnL: BigDecimal?, // soldFiat - (soldCrypto * avgBuyPrice) + val unrealizedPnL: BigDecimal?, // currentValueFiat - (held * avgBuyPrice) + val netPnL: BigDecimal?, // realized + unrealized + val targetProgressPct: Double? // netPnL / targetProfitAmount +) +``` + +Počítá se on-the-fly v ViewModelu. Žádná perzistence. + +## 4. Exchange API rozšíření + +### Nové metody na `ExchangeApi` + +```kotlin +interface ExchangeApi { + // existující metody beze změn + + suspend fun limitSell( + crypto: String, + fiat: String, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal + ): DcaResult = throw UnsupportedOperationException( + "AccBot zatím nepodporuje limit sell pro ${exchange.displayName}" + ) + + suspend fun cancelOrder(orderId: String): Result = + Result.failure(UnsupportedOperationException( + "AccBot zatím nepodporuje cancel order pro ${exchange.displayName}" + )) + + val supportsLimitSell: Boolean get() = false +} +``` + +### Refactor `getOrderStatus` + +Stávající signature `Transaction?` nepokrývá partial fill. Refactor na: + +```kotlin +data class OrderStatusResult( + val status: TransactionStatus, // PENDING/PARTIAL/COMPLETED/FAILED + val filledCryptoAmount: BigDecimal, + val filledFiatAmount: BigDecimal, + val avgFillPrice: BigDecimal?, + val fee: BigDecimal?, + val feeAsset: String? +) + +suspend fun getOrderStatus(orderId: String): OrderStatusResult? = null +``` + +**Breaking change** pro existující callery - migrace kódu: +- `CoinbaseApi.getOrderStatus` - přemapovat z `Transaction?` na `OrderStatusResult?` +- `OtherExchanges.kt` (KrakenApi má getOrderStatus) - dtto +- `ResolvePendingTransactionsUseCase` - update mapování + +### MVP support matrix + +| Burza | `limitSell` | `cancelOrder` | `getOrderStatus` | +|---|---|---|---| +| Coinmate | ANO | ANO | ANO (nový/refactor) | +| Binance | ANO | ANO | ANO (nový) | +| Coinbase | NE (default) | NE | refactor existujícího | +| Kraken | NE | NE | refactor existujícího | +| KuCoin / Bitfinex / Huobi | NE | NE | NE | + +Coinmate i Binance přepíšou `supportsLimitSell = true` v override; ostatní burzy ho nechávají na default `false`. + +UI gating přes `supportsLimitSell` - tlačítka pro nepodporované burzy nejsou viditelná, případné existující plány s `allowSells=true` na nepodporované burze zobrazí warning místo sell sekce. + +### REST endpointy + +**Coinmate:** +- `POST /api/sellLimit` - založení +- `POST /api/cancelOrder` - cancel +- `POST /api/orderById` - status (`status: OPEN/FILLED/PARTIALLY_FILLED/CANCELLED`, `remainingAmount`, `originalAmount`) + +**Binance:** +- `POST /api/v3/order` (`type=LIMIT, side=SELL, timeInForce=GTC`) +- `DELETE /api/v3/order` +- `GET /api/v3/order` (`status`, `executedQty`, `cummulativeQuoteQty`) + +Sandbox: Coinmate sandbox + Binance testnet podporují limit ordery, fungují identicky s produkcí. + +## 5. Sell flow (UX) + +### Wizard - 2-step bottom-sheet + +**Krok 1: Zadání objednávky** + +Inputy: +- **Množství** (crypto, defaultně focused). Quick-select chipy `25% / 50% / 75% / Vše` z `currentCryptoHeld - sum(open sell requested)`. Toggle na vstup ve fiatu (přepočet podle limitní ceny). +- **Limitní cena** (fiat). Quick-select chipy: + - `Tržní` = aktuální spot price + - `Breakeven` = `avgBuyPrice` plánu + - `+10%`, `+25%` = relativně k `avgBuyPrice` + +Live souhrn: +- Získáte: `množství × limitní cena` +- Zisk vs prům: `(limitní - avgBuy) × množství` (zelená/červená, fiat + %) +- Cílová cena: `(limitní - spot) / spot` (informativní) + +**Validace inline:** +- Množství > `currentCryptoHeld - sum(open sell requested)` → red error "Nemáte tolik BTC k dispozici (k dispozici X)" +- Množství < min order size burzy → red error +- Limit price <= spot → ⚡ info banner "Prodej proběhne okamžitě - příkaz se zfilluje ihned za nejvyšší nabídku na burze (obvykle blízko tržní ceny minus spread). Není to chyba." (ne-blokující) +- Limit price > spot × 3 → ⚠ warning "Cena vysoko nad trhem - prodej se nemusí zfillovat dlouho" +- Limit price <= 0 → red error + +**Krok 2: Potvrzení** + +Souhrn (burza, plán, side, množství, limit, získáte) + warning text "Akce odešle příkaz na {burzu} a nelze ji vrátit. Příkaz lze poté zrušit, dokud není zfillován." + +`Odeslat`: +1. Disable wizard, show spinner +2. `exchangeApi.limitSell(...)` +3. Success → zápis `TransactionEntity(side=SELL, status=PENDING, exchangeOrderId, limitPrice, requestedCryptoAmount=množství, cryptoAmount=0, fiatAmount=0)` → toast "Příkaz vytvořen" → close wizard → trigger immediate poll +4. Failure → inline error v Krok 2 + button Zpět + Zkusit znovu (žádný DB zápis) +5. Network timeout → dialog "Nelze ověřit stav. Zkontroluj na burze." (žádný DB zápis) + +### Sell sekce na plan-detailu + +Mezi existující buy info a transaction history: + +``` +[Buy info] +───────── +P&L card (drženo, prům. nákup, realizovaný, nerealizovaný, net, cíl progress) +Otevřené sell ordery (list s cancel ikonkou, partial fill progress) +[ + Vytvořit prodejní příkaz ] +───────── +[Transaction history] +``` + +Cancel ikonka u open orderu → confirm dialog → `cancelOrder()` → DB update na `status=FAILED` (nebo `PARTIAL` pokud filled > 0). + +## 6. Order tracking + polling + +### Polling triggery + +1. **App onResume** - `ProcessLifecycle` observer volá `ResolvePendingTransactionsUseCase` jednou +2. **DcaWorker tick** - piggyback (už dnes) +3. **Po placement / cancel sell orderu** - okamžitý poll (free, ověří propsání) +4. **Pull-to-refresh na plan-detail** - explicit user action s loading spinnerem +5. **Periodic worker (opt-in)** - `SellPollingWorker` reuse pattern z `DcaWorker` (AlarmManager + cron next-fire) + +### UC query rozšíření + +```kotlin +@Query(""" + SELECT * FROM transactions + WHERE status IN ('PENDING', 'PARTIAL') + AND exchangeOrderId IS NOT NULL +""") +suspend fun getResolvablePendingTransactions(): List +``` + +PARTIAL stav se taky pollluje (může se postupně doplnit do COMPLETED). + +### UC update logika + +Pro každou transakci: +- Načti credentials (existující path s connectionId) +- `api.getOrderStatus(orderId)` → `OrderStatusResult?` +- Mapování: + - `OPEN` → no change + - `PARTIALLY_FILLED` → update `cryptoAmount=filled`, `fiatAmount=filled*avg`, `status=PARTIAL` + - `FILLED` → update na final, `status=COMPLETED` + - `CANCELED` / `EXPIRED` → `status=FAILED` (nebo `PARTIAL` pokud filled > 0) + - `null` (order neznámý burze) → log warning, no change +- UPDATE query musí mít `WHERE status IN ('PENDING', 'PARTIAL')` jako concurrency guard (cancel mezitím nemění) + +### Reaktivní propagace + +Plan-detail ViewModel čte transakce přes Room Flow (existující `observeTransactionsForPlan(planId)` - ověřit; pokud chybí, přidáme `observe` variantu standardním Room patternem). UC update → Flow emit → UI re-render. + +### Periodic SellPollingWorker + +- Reuse `DcaFrequency` enum (`EVERY_15_MIN`, `HOURLY`, `EVERY_4_HOURS`, `EVERY_8_HOURS`, `DAILY`, `WEEKLY`, `CUSTOM`) +- Reuse `ScheduleBuilderState` Compose komponenta pro DAILY/WEEKLY/CUSTOM (extrahovat do shared composable pokud ještě není) +- AlarmManager pattern stejný jako `DcaWorker` +- Auto-skip když `transactionDao().countOpenSells() == 0` +- Constraints: `NetworkType.CONNECTED` +- Cancel při vypnutí toggle: `WorkManager.cancelUniqueWork("sell_polling")` +- Default frequency: `HOURLY` + +## 7. UI placement + +### 7.1 SettingsScreen + +Nová sekce "Pokročilé": +- `[ ]` Povolit prodeje (master gate, default OFF) +- Dimmed dokud master OFF: + - `[ ]` Kontrolovat sell ordery na pozadí + - Frekvence dropdown (`DcaFrequency` options) + visual schedule builder pro DAILY/WEEKLY/CUSTOM + - Warning text o spotřebě baterie / API limitech + +Při vypnutí master toggle: periodic sell polling auto-disable, worker cancel. Plány s `allowSells=true` zachovány v DB, jen UI sell sekce se skryje. + +### 7.2 AddPlanScreen / EditPlanScreen + +Když `isTradingEnabled = false` → žádné nové UI (skryté). + +Když `true`, nová sekce "Prodeje (volitelné)" na konci formuláře: +- `[ ]` Povolit prodeje pro tento plán +- Cíl zisku (volitelné, pouze pokud allowSells ON) - input v `plan.fiat` + +V edit-mode pokud user vypne `allowSells` a má open sell ordery → confirm dialog s informací o orderech na burze. Allow continue. + +### 7.3 PlanDetailsScreen + +Sell sekce zobrazená pouze když `plan.allowSells && global.isTradingEnabled && exchangeApi.supportsLimitSell`: + +- P&L card (Drženo, Prům. nákup, Realizovaný, Nerealizovaný, Net, Cíl progress bar pokud `targetProfitAmount` set) +- Open orders list s cancel button (a partial fill progress bar pokud filled > 0) +- "Vytvořit prodejní příkaz" button (disabled pokud held = 0 nebo pod min order size) + +### 7.4 Chart sell markery + +Existující chart na plan-detailu: +- BUY = malý zelený trojúhelník nahoru ▲ +- SELL = malý červený trojúhelník dolů ▼ +- Klik/long-press → tooltip (množství, cena, status) + +Sells **nemění historickou křivku invested** (= sum buy fiat). **Mění** křivku held value (klesne v čase sellu). + +### 7.5 HistoryScreen + TransactionDetailsScreen + +HistoryScreen: +- Item dostane směrovou ikonu/badge (BUY ↓ zelená, SELL ↑ červená) +- Filter chip: `Vše | Nákupy | Prodeje | Pending` +- Amounty s znaménkem (`-0.01 BTC / +12 500 CZK` pro SELL) + +TransactionDetailsScreen pro SELL navíc: +- Limitní cena +- Vyplněno: X / Y BTC (Z%) +- Avg fill price +- Cancel button (status v PENDING/PARTIAL) + +### 7.6 PortfolioScreen + +- **Sumární BTC drženo** = `sum(buy crypto) - sum(sell crypto)` +- **Celkem investováno** = `sum(buy fiat)` (beze změny) +- **Celkem realizováno** (nové, jen pokud > 0) = `sum(sell fiat)` +- **Net P&L portfolia** (nové, jen pokud trading enabled) = `currentValue + realized - invested` +- Agregátní křivka: sells "ujídají" z held value křivky, invested zůstává monotónně rostoucí + +### 7.7 DashboardScreen + +Nová karta (jen pro plány s `allowSells=true` a aspoň jedním open sell orderem): +``` +📤 BTC stack: 1 open sell +0.01 BTC @ 1 250 000 CZK +Aktuální tržní: 1 180 245 +``` +Klik → plan-detail. + +## 8. Edge cases & error handling + +### 8.1 Insufficient balance + +Server-side fail (Coinmate `ERROR_INSUFFICIENT_FUNDS`, Binance `-2010`) → `DcaResult.Failure(INSUFFICIENT_BALANCE)` → wizard inline error → no DB write. + +### 8.2 Partial fill + cancel + +User cancel po partial fill: `status=PARTIAL`, filled hodnoty zachovány. P&L bere `cryptoAmount` (filled), ne requested. + +### 8.3 Out-of-band cancel (web) + +Polling detekuje `CANCELED` → status=FAILED nebo PARTIAL. Žádné notifikace. + +### 8.4 Placement timeout + +Dialog "Nelze ověřit stav. Zkontroluj na burze." Žádný DB write. Lepší false negative než duplicitní order. + +### 8.5 Multiple open sells + +Validace amount = `held - sum(open sell requested)`. Server-side error zachytí race. + +### 8.6 Sandbox mode + +Existující `isSandboxMode()` orthogonal. Limit sells jdou na Coinmate sandbox / Binance testnet. Manuální sandbox sell loop před release. + +### 8.7 Concurrency: polling vs cancel + +UPDATE query s `WHERE status IN ('PENDING', 'PARTIAL')` slouží jako optimistic lock. Cancel mění na FAILED → následující polling update nic neudělá. + +### 8.8 Plán delete s open ordery + +**Block delete** s alertem "Plán má X open sell ordery. Zruš je nejdřív." User musí explicitně cancelovat. + +### 8.9 Target overshoot + +Žádný side effect. Progress bar capped vizuálně na 100% s textem (např. "130%" nebo "Cíl dosažen"). User pokračuje normálně. + +### 8.10 P&L NaN edge cases + +- Bez buy tx → `avgBuyPrice = null` → realized/unrealized = null → UI "—" +- Bez spot price → `unrealizedPnL = null` → UI "—" +- `realized` se počítá jen pokud `totalBoughtCrypto > 0` (zero-div guard) + +## 9. Testing + +### Unit +- `PlanPnL` kalkulace pro různé scénáře (no buys, no sells, partial fills, missing spot) +- `ResolvePendingTransactionsUseCase` mapování `OrderStatusResult` → DB update pro každý status +- Validace v sell wizardu (amount, price thresholds) +- Multi-open-sell validace (`held - sum(open sell requested)`) + +### Integration +- Coinmate sandbox: place limit sell pod tržní (instant fill), nad tržní (open), partial fill simulation, cancel +- Binance testnet: stejný matrix +- Migration v19 → v20 idempotence +- Backup roundtrip s/bez nových polí + +### Manual (před release) +- Full E2E na Coinmate sandbox: vytvoření trading plánu, buy několik tx, založit sell, sledovat polling, cancel +- Network timeout simulace při placement (DB ne-zápis verifikace) +- Plán delete s open ordery (block alert) +- Settings master toggle off → sell UI mizí, plány nedotčené + +## 10. Rollout + +- Feature gated globálním Settings toggle (default OFF) - safe to ship +- Coinmate + Binance support na release; ostatní burzy zobrazí "AccBot zatím nepodporuje" hlášku +- Periodic sell polling default OFF - user musí explicit opt-in +- Před release: manuální sandbox sell loop na Coinmate i Binance From 71eb3d502b0af9c7d79cc4ecc5e778b18bd43fd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 21:50:25 +0200 Subject: [PATCH 02/75] docs(spec): fix migration version to v20->v21 DB je aktualne na version 20 (po merge multi-connection-envelopes byla zabrana migrace 19->20 pro plan name + displayOrder). Co-Authored-By: Claude Opus 4.7 --- .../superpowers/specs/2026-04-23-dca-sell-extension-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-04-23-dca-sell-extension-design.md b/docs/superpowers/specs/2026-04-23-dca-sell-extension-design.md index 4c30dc4..cc0a543 100644 --- a/docs/superpowers/specs/2026-04-23-dca-sell-extension-design.md +++ b/docs/superpowers/specs/2026-04-23-dca-sell-extension-design.md @@ -127,7 +127,7 @@ Progress fill v UI = `cryptoAmount / requestedCryptoAmount`. `requestedCryptoAmount` zůstává fixní napříč všemi stavy. -### Room migrace v19 → v20 +### Room migrace v20 → v21 ```sql ALTER TABLE dca_plans ADD COLUMN allowSells INTEGER NOT NULL DEFAULT 0; @@ -471,7 +471,7 @@ UPDATE query s `WHERE status IN ('PENDING', 'PARTIAL')` slouží jako optimistic ### Integration - Coinmate sandbox: place limit sell pod tržní (instant fill), nad tržní (open), partial fill simulation, cancel - Binance testnet: stejný matrix -- Migration v19 → v20 idempotence +- Migration v20 → v21 idempotence - Backup roundtrip s/bez nových polí ### Manual (před release) From ca1bf23c6f0ab552e57f486591a0107ff714654f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 21:50:45 +0200 Subject: [PATCH 03/75] docs(plan): implementacni plan pro DCA sell extension 34 tasku v 9 fazich. Datovy model (migrace v20->v21), rozsireni ExchangeApi (Coinmate + Binance limit sell), use cases, UI (settings + plan detail + wizard + chart/history/portfolio). Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-04-23-dca-sell-extension.md | 2930 +++++++++++++++++ 1 file changed, 2930 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-23-dca-sell-extension.md diff --git a/docs/superpowers/plans/2026-04-23-dca-sell-extension.md b/docs/superpowers/plans/2026-04-23-dca-sell-extension.md new file mode 100644 index 0000000..8523ecc --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-dca-sell-extension.md @@ -0,0 +1,2930 @@ +# DCA Sell Extension - implementacni plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Cil:** Rozsirit DCA plany o opt-in limitni prodejni prikazy, P&L tracking a volitelny cil zisku. Globalni Settings toggle + per-plan `allowSells` flag. MVP podpora Coinmate + Binance. + +**Architektura:** Minimum invasive na existujici kod. 2 nova pole na `DcaPlan`, 3 pole na `TransactionEntity`, 3 metody v `ExchangeApi`. Sells jsou obycejne transakce s `side=SELL`. Existujici `ResolvePendingTransactionsUseCase` rozsirime na polling BUY i SELL side, polling triggery: app onResume + DcaWorker tick + po user akci + pull-to-refresh + opt-in periodic worker. + +**Tech Stack:** Kotlin, Jetpack Compose, Room, Hilt, WorkManager, AlarmManager, OkHttp + +**Referencni spec:** `docs/superpowers/specs/2026-04-23-dca-sell-extension-design.md` + +**Pozn. k testum:** Projekt nema unit test infrastructure (jen `androidTest` pro screenshoty/recording). Manualni verifikace po kazdem tasku: `./gradlew assembleDebug` ze `accbot-android/`. Funkcni testy: sandbox mode + realny run. + +--- + +## Faze 1: Datovy model + +### Task 1: Pridat TransactionSide enum a rozsirit TransactionEntity + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt` + +- [ ] **Krok 1: Pridat TransactionSide enum** + +V `Entities.kt` nad `TransactionEntity`: + +```kotlin +enum class TransactionSide { + BUY, + SELL +} +``` + +- [ ] **Krok 2: Pridat 3 nova pole do TransactionEntity** + +```kotlin +@Entity( + tableName = "transactions", + // ... existujici +) +data class TransactionEntity( + // ... vsechna existujici pole beze zmeny ... + val side: TransactionSide = TransactionSide.BUY, + val limitPrice: BigDecimal? = null, + val requestedCryptoAmount: BigDecimal? = null +) +``` + +- [ ] **Krok 3: Pridat TypeConverter pro TransactionSide** + +V `Entities.kt` do `Converters` tridy: + +```kotlin +@TypeConverter +fun fromTransactionSide(side: TransactionSide): String = side.name + +@TypeConverter +fun toTransactionSide(value: String): TransactionSide = + TransactionSide.valueOf(value) +``` + +- [ ] **Krok 4: Build check** + +```bash +cd accbot-android +./gradlew assembleDebug +``` + +Expected: SUCCESS + +- [ ] **Krok 5: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt +git commit -m "feat(sell): add TransactionSide enum and sell-specific fields to TransactionEntity" +``` + +--- + +### Task 2: Rozsirit DcaPlanEntity o allowSells + targetProfitAmount + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt` + +- [ ] **Krok 1: Pridat pole do DcaPlanEntity** + +```kotlin +@Entity( + tableName = "dca_plans", + // ... existujici +) +data class DcaPlanEntity( + // ... vsechna existujici pole beze zmeny ... + val allowSells: Boolean = false, + val targetProfitAmount: BigDecimal? = null +) +``` + +- [ ] **Krok 2: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 3: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt +git commit -m "feat(sell): add allowSells + targetProfitAmount to DcaPlanEntity" +``` + +--- + +### Task 3: Napsat Room migraci 20 -> 21 + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt` + +- [ ] **Krok 1: Pridat MIGRATION_20_21** + +Pod `MIGRATION_19_20` v companion objectu: + +```kotlin +private val MIGRATION_20_21 = object : Migration(20, 21) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE dca_plans ADD COLUMN allowSells INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE dca_plans ADD COLUMN targetProfitAmount TEXT DEFAULT NULL") + database.execSQL("ALTER TABLE transactions ADD COLUMN side TEXT NOT NULL DEFAULT 'BUY'") + database.execSQL("ALTER TABLE transactions ADD COLUMN limitPrice TEXT DEFAULT NULL") + database.execSQL("ALTER TABLE transactions ADD COLUMN requestedCryptoAmount TEXT DEFAULT NULL") + database.execSQL("CREATE INDEX IF NOT EXISTS idx_tx_plan_side_status ON transactions(planId, side, status)") + } +} +``` + +- [ ] **Krok 2: Upravit @Database version** + +Zmenit `version = 20` na `version = 21`: + +```kotlin +@Database( + entities = [...], + version = 21, + exportSchema = true +) +``` + +- [ ] **Krok 3: Zaregistrovat migraci v addMigrations(...)** + +V `createDatabase` / builderu pridat na konec listu: + +```kotlin +.addMigrations( + MIGRATION_1_2, MIGRATION_2_3, /* ... */ MIGRATION_19_20, MIGRATION_20_21 +) +``` + +- [ ] **Krok 4: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +Expected: SUCCESS. Pokud failne s "Schema export... mismatch", spustit `./gradlew :app:kspDebugKotlin` a zkontrolovat `schemas/com.accbot.dca.data.local.DcaDatabase/21.json`. + +- [ ] **Krok 5: Manualni test migrace** + +Nainstaluj pres `./gradlew installDebug` na emulator ktery ma starou DB (version 20). App musi nastartovat bez crashe. Logcat: `adb logcat | grep -i "room\|migration"` nesmi hlasit `IllegalStateException`. + +- [ ] **Krok 6: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt +git commit -m "feat(sell): Room migration 20->21 for sell extension fields" +``` + +--- + +### Task 4: Rozsirit DcaPlan domain model + mapper + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt` (nebo kde je `DcaPlan` data class) +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt` + +- [ ] **Krok 1: Najit DcaPlan domain data class** + +```bash +grep -rn 'data class DcaPlan[^E]' accbot-android/app/src/main/java/com/accbot/dca/ +``` + +- [ ] **Krok 2: Pridat pole do DcaPlan domain modelu** + +```kotlin +data class DcaPlan( + // ... existujici pole beze zmeny ... + val allowSells: Boolean = false, + val targetProfitAmount: BigDecimal? = null +) +``` + +- [ ] **Krok 3: Upravit mapper Entity -> Domain** + +V `EntityMappers.kt` najit `DcaPlanEntity.toDomain()` a pridat: + +```kotlin +fun DcaPlanEntity.toDomain(): DcaPlan = DcaPlan( + // ... existujici mapping ... + allowSells = allowSells, + targetProfitAmount = targetProfitAmount +) +``` + +- [ ] **Krok 4: Upravit mapper Domain -> Entity** + +```kotlin +fun DcaPlan.toEntity(): DcaPlanEntity = DcaPlanEntity( + // ... existujici mapping ... + allowSells = allowSells, + targetProfitAmount = targetProfitAmount +) +``` + +- [ ] **Krok 5: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 6: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/ +git commit -m "feat(sell): extend DcaPlan domain model + mappers" +``` + +--- + +### Task 5: Rozsirit Transaction domain model + mapper + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/domain/model/Transaction.kt` (nebo kde je `Transaction` data class) +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt` + +- [ ] **Krok 1: Najit Transaction domain data class** + +```bash +grep -rn 'data class Transaction[^E]' accbot-android/app/src/main/java/com/accbot/dca/ +``` + +- [ ] **Krok 2: Pridat pole do Transaction** + +```kotlin +data class Transaction( + // ... existujici pole ... + val side: TransactionSide = TransactionSide.BUY, + val limitPrice: BigDecimal? = null, + val requestedCryptoAmount: BigDecimal? = null +) +``` + +- [ ] **Krok 3: Upravit mappery TransactionEntity <-> Transaction** + +V `EntityMappers.kt`: + +```kotlin +fun TransactionEntity.toDomain(): Transaction = Transaction( + // ... existujici mapping ... + side = side, + limitPrice = limitPrice, + requestedCryptoAmount = requestedCryptoAmount +) + +fun Transaction.toEntity(): TransactionEntity = TransactionEntity( + // ... existujici mapping ... + side = side, + limitPrice = limitPrice, + requestedCryptoAmount = requestedCryptoAmount +) +``` + +- [ ] **Krok 4: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 5: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/ +git commit -m "feat(sell): extend Transaction domain model + mappers" +``` + +--- + +### Task 6: Rozsirit TransactionDao o sell-aware queries + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt` + +- [ ] **Krok 1: Pridat query pro resolvable pending + partial transakce** + +```kotlin +@Query(""" + SELECT * FROM transactions + WHERE status IN ('PENDING', 'PARTIAL') + AND exchangeOrderId IS NOT NULL +""") +suspend fun getResolvablePendingTransactions(): List +``` + +- [ ] **Krok 2: Pridat query pro pocitani open sells** + +```kotlin +@Query(""" + SELECT COUNT(*) FROM transactions + WHERE side = 'SELL' + AND status IN ('PENDING', 'PARTIAL') +""") +suspend fun countOpenSells(): Int +``` + +- [ ] **Krok 3: Pridat query pro open sells konkretniho planu** + +```kotlin +@Query(""" + SELECT * FROM transactions + WHERE planId = :planId + AND side = 'SELL' + AND status IN ('PENDING', 'PARTIAL') + ORDER BY executedAt DESC +""") +fun observeOpenSellsForPlan(planId: Long): Flow> +``` + +- [ ] **Krok 4: Pridat query pro vsechny transakce planu (observe variant, pokud neexistuje)** + +Zkontroluj jestli existuje `fun observeTransactionsForPlan(planId: Long): Flow>`. Pokud neexistuje: + +```kotlin +@Query("SELECT * FROM transactions WHERE planId = :planId ORDER BY executedAt DESC") +fun observeTransactionsForPlan(planId: Long): Flow> +``` + +- [ ] **Krok 5: Concurrency-guarded update pro resolve** + +```kotlin +@Query(""" + UPDATE transactions + SET status = :newStatus, + cryptoAmount = :cryptoAmount, + fiatAmount = :fiatAmount, + price = :price, + fee = :fee + WHERE id = :id + AND status IN ('PENDING', 'PARTIAL') +""") +suspend fun updateResolvedTransaction( + id: Long, + newStatus: TransactionStatus, + cryptoAmount: BigDecimal, + fiatAmount: BigDecimal, + price: BigDecimal, + fee: BigDecimal? +): Int // returns affected row count +``` + +- [ ] **Krok 6: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 7: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt +git commit -m "feat(sell): add sell-aware DAO queries (open sells, resolvable, guarded update)" +``` + +--- + +### Task 7: Rozsirit BackupPlan + BackupTransaction modely + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt` +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataCollector.kt` +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt` + +- [ ] **Krok 1: Rozsirit BackupPlan** + +V `BackupModels.kt`: + +```kotlin +data class BackupPlan( + // ... existujici pole ... + @SerializedName("allowSells") val allowSells: Boolean = false, + @SerializedName("targetProfitAmount") val targetProfitAmount: String? = null // BigDecimal jako String +) +``` + +- [ ] **Krok 2: Rozsirit BackupTransaction** + +```kotlin +data class BackupTransaction( + // ... existujici pole ... + @SerializedName("side") val side: String = "BUY", // TransactionSide.name + @SerializedName("limitPrice") val limitPrice: String? = null, + @SerializedName("requestedCryptoAmount") val requestedCryptoAmount: String? = null +) +``` + +- [ ] **Krok 3: Upravit BackupDataCollector - ukladat nova pole** + +Najit kde se vytvareji `BackupPlan` a `BackupTransaction` v `BackupDataCollector.kt`, pridat: + +```kotlin +BackupPlan( + // ... existujici ... + allowSells = plan.allowSells, + targetProfitAmount = plan.targetProfitAmount?.toPlainString() +) + +BackupTransaction( + // ... existujici ... + side = tx.side.name, + limitPrice = tx.limitPrice?.toPlainString(), + requestedCryptoAmount = tx.requestedCryptoAmount?.toPlainString() +) +``` + +- [ ] **Krok 4: Upravit BackupDataRestorer - nacitat nova pole** + +V `BackupDataRestorer.kt` pri konstrukci entit z backup modelu: + +```kotlin +DcaPlanEntity( + // ... existujici ... + allowSells = backupPlan.allowSells, + targetProfitAmount = backupPlan.targetProfitAmount?.let { BigDecimal(it) } +) + +TransactionEntity( + // ... existujici ... + side = TransactionSide.valueOf(backupTx.side), + limitPrice = backupTx.limitPrice?.let { BigDecimal(it) }, + requestedCryptoAmount = backupTx.requestedCryptoAmount?.let { BigDecimal(it) } +) +``` + +- [ ] **Krok 5: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 6: Verifikace ProGuard keep rules** + +Ve `proguard-rules.pro` (nebo kde jsou ProGuard rules) overit ze `com.accbot.dca.domain.model.**` je v keep rules. Nova pole maji `@SerializedName` anotaci - release build musi fungovat. Pokud package neni v keep, pridat: + +```proguard +-keep class com.accbot.dca.domain.model.** { *; } +``` + +- [ ] **Krok 7: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/ +git commit -m "feat(sell): extend backup/restore for sell fields" +``` + +--- + +## Faze 2: Exchange API + +### Task 8: Definovat OrderStatusResult a refactor ExchangeApi + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/exchange/ExchangeApi.kt` + +- [ ] **Krok 1: Pridat OrderStatusResult data class** + +Do `ExchangeApi.kt` nebo noveho souboru `OrderStatusResult.kt`: + +```kotlin +package com.accbot.dca.exchange + +import com.accbot.dca.domain.model.TransactionStatus +import java.math.BigDecimal + +data class OrderStatusResult( + val status: TransactionStatus, // PENDING/PARTIAL/COMPLETED/FAILED + val filledCryptoAmount: BigDecimal, + val filledFiatAmount: BigDecimal, + val avgFillPrice: BigDecimal?, + val fee: BigDecimal?, + val feeAsset: String? +) +``` + +- [ ] **Krok 2: Refactor getOrderStatus signature** + +V `ExchangeApi.kt` interface (definitivní signatura, pouzita ve vsech implementacich): + +```kotlin +suspend fun getOrderStatus(orderId: String, crypto: String, fiat: String): OrderStatusResult? = null +``` + +(Drive vracelo `Transaction?` a bralo jen `orderId` - breaking change.) + +- [ ] **Krok 3: Pridat limitSell metodu** + +```kotlin +/** + * Place a limit sell order. Order zustava otevreny na burze. + * @return DcaResult.Success s exchangeOrderId a status=PENDING. + * Failure pokud burza odmitne (insufficient balance, invalid price). + */ +suspend fun limitSell( + crypto: String, + fiat: String, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal +): DcaResult = throw UnsupportedOperationException( + "AccBot zatim nepodporuje limit sell pro ${exchange.displayName}" +) +``` + +- [ ] **Krok 4: Pridat cancelOrder metodu** + +**Signature poznamka:** Binance vyzaduje `symbol=${crypto}${fiat}` navic k `orderId`. Aby byla signature konzistentni pro vsechny burzy, pridavame `crypto + fiat` parametry (Coinmate/Coinbase/Kraken je ignoruji). + +```kotlin +/** + * Cancel an open order on the exchange. + * @param crypto, fiat - nektere burzy (Binance) vyzaduji symbol ke cancelu + * @return Result.success pokud order byl zrusen (nebo uz byl filled/canceled). + * Result.failure pokud burza odmitla / nedostupna. + */ +suspend fun cancelOrder(orderId: String, crypto: String, fiat: String): Result = + Result.failure(UnsupportedOperationException( + "AccBot zatim nepodporuje cancel order pro ${exchange.displayName}" + )) +``` + +Zaroven upravit getOrderStatus signaturu: + +```kotlin +suspend fun getOrderStatus(orderId: String, crypto: String, fiat: String): OrderStatusResult? = null +``` + +- [ ] **Krok 5: Pridat supportsLimitSell capability flag** + +```kotlin +val supportsLimitSell: Boolean get() = false +``` + +- [ ] **Krok 6: Build check - bude failovat kvuli breaking change** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +Expected: FAIL na callers `getOrderStatus` (CoinbaseApi, KrakenApi, ResolvePendingTransactionsUseCase). Fix prichazi v nasledujicich tascich. + +- [ ] **Krok 7: Commit (broken build, fix v dalsich tascich)** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/exchange/ +git commit -m "feat(sell): add limitSell/cancelOrder/supportsLimitSell + refactor getOrderStatus to OrderStatusResult" +``` + +--- + +### Task 9: Refactor existujicich getOrderStatus implementaci (Coinbase + Kraken) + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt` +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt` (KrakenApi) + +- [ ] **Krok 1: Precti aktualni CoinbaseApi.getOrderStatus** + +```bash +grep -n -A 30 'getOrderStatus' accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt +``` + +- [ ] **Krok 2: Refactor CoinbaseApi.getOrderStatus na OrderStatusResult** + +Stavajici kod vraci `Transaction?` - prepsat aby vracelo `OrderStatusResult?`. Nova signature: `(orderId, crypto, fiat)` (crypto+fiat se pro Coinbase ignoruji, ale interface je sjednoceny). + +```kotlin +override suspend fun getOrderStatus(orderId: String, crypto: String, fiat: String): OrderStatusResult? = withContext(Dispatchers.IO) { + // ... existujici API call ... + // Misto vraceni Transaction(...) vrat OrderStatusResult(...) + + val status = when (coinbaseStatusString) { + "OPEN" -> TransactionStatus.PENDING + "PARTIALLY_FILLED" -> TransactionStatus.PARTIAL + "FILLED" -> TransactionStatus.COMPLETED + "CANCELLED", "EXPIRED" -> if (filledSize > BigDecimal.ZERO) + TransactionStatus.PARTIAL else TransactionStatus.FAILED + else -> return@withContext null + } + + OrderStatusResult( + status = status, + filledCryptoAmount = filledSize, + filledFiatAmount = filledValue, + avgFillPrice = if (filledSize > BigDecimal.ZERO) + filledValue.divide(filledSize, 8, RoundingMode.HALF_UP) else null, + fee = fee, + feeAsset = feeAsset + ) +} +``` + +(Detail mapovani Coinbase statusu si dohledat v dokumentaci / existujicim kodu.) + +- [ ] **Krok 3: Refactor KrakenApi.getOrderStatus v OtherExchanges.kt** + +Obdobne - stejna nova signature `(orderId, crypto, fiat)` (crypto+fiat pro Kraken ignoruji). `status` Kraken pouziva `open/closed/canceled/expired`: + +```kotlin +override suspend fun getOrderStatus(orderId: String, crypto: String, fiat: String): OrderStatusResult? = withContext(Dispatchers.IO) { + // ... API call ... + val status = when (krakenStatus) { + "open" -> if (vol_exec > BigDecimal.ZERO) TransactionStatus.PARTIAL else TransactionStatus.PENDING + "closed" -> TransactionStatus.COMPLETED + "canceled", "expired" -> if (vol_exec > BigDecimal.ZERO) + TransactionStatus.PARTIAL else TransactionStatus.FAILED + else -> return@withContext null +} + +OrderStatusResult( + status = status, + filledCryptoAmount = vol_exec, + filledFiatAmount = cost, + avgFillPrice = if (vol_exec > BigDecimal.ZERO) + cost.divide(vol_exec, 8, RoundingMode.HALF_UP) else null, + fee = fee, + feeAsset = null +) +``` + +- [ ] **Krok 4: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +Expected: stale failne na `ResolvePendingTransactionsUseCase` (fix v Task 12). + +- [ ] **Krok 5: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/exchange/ +git commit -m "refactor(sell): adapt CoinbaseApi + KrakenApi to OrderStatusResult" +``` + +--- + +### Task 10: Implementovat CoinmateApi.limitSell + cancelOrder + getOrderStatus + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt` + +- [ ] **Krok 1: Override supportsLimitSell** + +V `CoinmateApi` classi: + +```kotlin +override val supportsLimitSell: Boolean = true +``` + +- [ ] **Krok 2: Implementovat limitSell** + +Coinmate API endpoint: `POST /api/sellLimit` s body: `amount, price, currencyPair, clientOrderId (optional)`. + +```kotlin +override suspend fun limitSell( + crypto: String, + fiat: String, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal +): DcaResult = withContext(Dispatchers.IO) { + val currencyPair = "${crypto}_${fiat}" + val params = buildSignedParams( + "amount" to cryptoAmount.stripTrailingZeros().toPlainString(), + "price" to limitPrice.stripTrailingZeros().toPlainString(), + "currencyPair" to currencyPair + ) + val response = executePostRequest("/api/sellLimit", params) + + if (response.error) { + return@withContext DcaResult.Failure( + exchange = Exchange.COINMATE, + reason = mapCoinmateErrorToReason(response.errorMessage), + message = response.errorMessage ?: "Coinmate sell limit failed" + ) + } + + val orderId = response.data?.toString() ?: return@withContext DcaResult.Failure(...) + + DcaResult.Success( + transaction = Transaction( + exchange = Exchange.COINMATE, + crypto = crypto, + fiat = fiat, + cryptoAmount = BigDecimal.ZERO, // not filled yet + fiatAmount = BigDecimal.ZERO, // not filled yet + price = limitPrice, + fee = null, + feeAsset = null, + status = TransactionStatus.PENDING, + exchangeOrderId = orderId, + side = TransactionSide.SELL, + limitPrice = limitPrice, + requestedCryptoAmount = cryptoAmount, + executedAt = Instant.now() + ) + ) +} +``` + +**Pozn.:** `buildSignedParams` / `executePostRequest` jsou existujici helpery v `CoinmateApi` - reuse. + +- [ ] **Krok 3: Implementovat cancelOrder** + +Coinmate: `POST /api/cancelOrder` s `orderId`. Parametry `crypto + fiat` se ignoruji (jsou tam kvuli interface konzistenci s Binance). + +```kotlin +override suspend fun cancelOrder(orderId: String, crypto: String, fiat: String): Result = withContext(Dispatchers.IO) { + try { + val params = buildSignedParams("orderId" to orderId) + val response = executePostRequest("/api/cancelOrder", params) + if (response.error) { + return@withContext Result.failure( + IOException("Coinmate cancel failed: ${response.errorMessage}") + ) + } + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } +} +``` + +- [ ] **Krok 4: Implementovat getOrderStatus** + +Coinmate: `POST /api/orderById` s `orderId`, vraci objekt s `status, remainingAmount, originalAmount, orderType, price, avgPrice, ...`. Parametry `crypto + fiat` se ignoruji. + +```kotlin +override suspend fun getOrderStatus(orderId: String, crypto: String, fiat: String): OrderStatusResult? = withContext(Dispatchers.IO) { + try { + val params = buildSignedParams("orderId" to orderId) + val response = executePostRequest("/api/orderById", params) + if (response.error) return@withContext null + + val order = response.data as? JSONObject ?: return@withContext null + val originalAmount = BigDecimal(order.getString("originalAmount")) + val remainingAmount = BigDecimal(order.getString("remainingAmount")) + val filledAmount = originalAmount - remainingAmount + val avgPrice = order.optString("avgPrice").takeIf { it.isNotBlank() }?.let { BigDecimal(it) } + val filledFiat = avgPrice?.let { filledAmount * it } ?: BigDecimal.ZERO + + val coinmateStatus = order.getString("status") + val txStatus = when (coinmateStatus) { + "OPEN" -> if (filledAmount > BigDecimal.ZERO) TransactionStatus.PARTIAL else TransactionStatus.PENDING + "FILLED" -> TransactionStatus.COMPLETED + "PARTIALLY_FILLED" -> TransactionStatus.PARTIAL + "CANCELLED", "EXPIRED" -> if (filledAmount > BigDecimal.ZERO) + TransactionStatus.PARTIAL else TransactionStatus.FAILED + else -> return@withContext null + } + + OrderStatusResult( + status = txStatus, + filledCryptoAmount = filledAmount, + filledFiatAmount = filledFiat, + avgFillPrice = avgPrice, + fee = null, // Coinmate order endpoint fee TBD - vracet null pokud endpoint nevraci + feeAsset = null + ) + } catch (e: Exception) { + Log.w("CoinmateApi", "getOrderStatus failed for $orderId", e) + null + } +} +``` + +- [ ] **Krok 5: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 6: Manualni sandbox test** + +Spustit app v sandbox mode (UserPreferences sandbox=true). Pres DEBUG UI (nebo adb shell) zavolat `limitSell` s malou castkou, pak `getOrderStatus`, pak `cancelOrder`. Verifikovat ze orderID prichazi, status se meni, cancel funguje. Logy pres `adb logcat | grep CoinmateApi`. + +- [ ] **Krok 7: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt +git commit -m "feat(sell): implement CoinmateApi limitSell + cancelOrder + getOrderStatus" +``` + +--- + +### Task 11: Implementovat BinanceApi.limitSell + cancelOrder + getOrderStatus + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt` + +- [ ] **Krok 1: Override supportsLimitSell** + +```kotlin +override val supportsLimitSell: Boolean = true +``` + +- [ ] **Krok 2: Implementovat limitSell** + +Binance: `POST /api/v3/order?symbol=BTCEUR&side=SELL&type=LIMIT&timeInForce=GTC&quantity=0.01&price=1200000`. + +```kotlin +override suspend fun limitSell( + crypto: String, + fiat: String, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal +): DcaResult = withContext(Dispatchers.IO) { + val symbol = "${crypto}${fiat}" + val params = mapOf( + "symbol" to symbol, + "side" to "SELL", + "type" to "LIMIT", + "timeInForce" to "GTC", + "quantity" to cryptoAmount.stripTrailingZeros().toPlainString(), + "price" to limitPrice.stripTrailingZeros().toPlainString() + ) + + try { + val response = executeSignedRequest("POST", "/api/v3/order", params) + val orderId = response.getLong("orderId").toString() + + DcaResult.Success( + transaction = Transaction( + exchange = Exchange.BINANCE, + crypto = crypto, + fiat = fiat, + cryptoAmount = BigDecimal.ZERO, + fiatAmount = BigDecimal.ZERO, + price = limitPrice, + fee = null, + feeAsset = null, + status = TransactionStatus.PENDING, + exchangeOrderId = orderId, + side = TransactionSide.SELL, + limitPrice = limitPrice, + requestedCryptoAmount = cryptoAmount, + executedAt = Instant.now() + ) + ) + } catch (e: BinanceApiException) { + DcaResult.Failure( + exchange = Exchange.BINANCE, + reason = mapBinanceErrorToReason(e.code), + message = e.message ?: "Binance limit sell failed" + ) + } +} +``` + +- [ ] **Krok 3: Implementovat cancelOrder** + +Binance: `DELETE /api/v3/order?symbol=BTCEUR&orderId=XXX`. Signature uz je `(orderId, crypto, fiat)` definovane v Task 8. + +```kotlin +override suspend fun cancelOrder(orderId: String, crypto: String, fiat: String): Result = withContext(Dispatchers.IO) { + try { + val symbol = "${crypto}${fiat}" + val params = mapOf("symbol" to symbol, "orderId" to orderId) + executeSignedRequest("DELETE", "/api/v3/order", params) + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } +} +``` + +- [ ] **Krok 4: Implementovat getOrderStatus** + +Binance: `GET /api/v3/order?symbol=BTCEUR&orderId=XXX`. + +```kotlin +override suspend fun getOrderStatus(orderId: String, crypto: String, fiat: String): OrderStatusResult? = withContext(Dispatchers.IO) { + try { + val symbol = "${crypto}${fiat}" + val params = mapOf("symbol" to symbol, "orderId" to orderId) + val response = executeSignedRequest("GET", "/api/v3/order", params) + + val binanceStatus = response.getString("status") + val executedQty = BigDecimal(response.getString("executedQty")) + val cumQuoteQty = BigDecimal(response.getString("cummulativeQuoteQty")) + val avgPrice = if (executedQty > BigDecimal.ZERO) + cumQuoteQty.divide(executedQty, 8, RoundingMode.HALF_UP) else null + + val txStatus = when (binanceStatus) { + "NEW" -> TransactionStatus.PENDING + "PARTIALLY_FILLED" -> TransactionStatus.PARTIAL + "FILLED" -> TransactionStatus.COMPLETED + "CANCELED", "EXPIRED", "REJECTED" -> if (executedQty > BigDecimal.ZERO) + TransactionStatus.PARTIAL else TransactionStatus.FAILED + else -> return@withContext null + } + + OrderStatusResult( + status = txStatus, + filledCryptoAmount = executedQty, + filledFiatAmount = cumQuoteQty, + avgFillPrice = avgPrice, + fee = null, // fee je per-fill, zjisti se z /myTrades endpoint - MVP: skip + feeAsset = null + ) + } catch (e: Exception) { + Log.w("BinanceApi", "getOrderStatus failed for $orderId", e) + null + } +} +``` + +- [ ] **Krok 5: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 6: Manualni Binance testnet test** + +Prepnout app na sandbox mode (uses Binance testnet). Zalozit plan na BTC/EUR, pak pres DEBUG UI vyvolat limitSell s malou castkou. Verifikovat orderID, status check, cancel. Logy `adb logcat | grep BinanceApi`. + +- [ ] **Krok 7: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/exchange/ +git commit -m "feat(sell): implement BinanceApi limitSell + cancel + getOrderStatus" +``` + +--- + +## Faze 3: Use cases a business logic + +### Task 12: Rozsirit ResolvePendingTransactionsUseCase + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt` + +- [ ] **Krok 1: Zmenit query z getPendingTransactionsWithOrderId na getResolvablePendingTransactions** + +```kotlin +val pendingTransactions = database.transactionDao().getResolvablePendingTransactions() +``` + +- [ ] **Krok 2: Zmenit volani getOrderStatus na novy signaturu** + +```kotlin +val filledOrder = api.getOrderStatus(orderId, tx.crypto, tx.fiat) ?: continue +``` + +- [ ] **Krok 3: Zmenit update logiku - pouzit guarded update** + +```kotlin +val rowsUpdated = database.transactionDao().updateResolvedTransaction( + id = tx.id, + newStatus = filledOrder.status, + cryptoAmount = filledOrder.filledCryptoAmount, + fiatAmount = filledOrder.filledFiatAmount, + price = filledOrder.avgFillPrice ?: tx.price, + fee = filledOrder.fee +) +if (rowsUpdated > 0) resolvedCount++ +``` + +- [ ] **Krok 4: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 5: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt +git commit -m "feat(sell): extend ResolvePendingTransactionsUseCase for SELL-side + PARTIAL resolution" +``` + +--- + +### Task 13: PlaceLimitSellUseCase + +**Soubory:** +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLimitSellUseCase.kt` + +- [ ] **Krok 1: Vytvorit use case** + +```kotlin +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.EntityMappers.toEntity +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.domain.model.DcaResult +import com.accbot.dca.exchange.ExchangeApiFactory +import java.math.BigDecimal +import javax.inject.Inject + +class PlaceLimitSellUseCase @Inject constructor( + private val database: DcaDatabase, + private val credentialsStore: CredentialsStore, + private val exchangeApiFactory: ExchangeApiFactory, + private val userPreferences: UserPreferences, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase +) { + suspend operator fun invoke( + planId: Long, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal + ): Result { + val plan = database.dcaPlanDao().getPlanById(planId) + ?: return Result.failure(IllegalArgumentException("Plan $planId neexistuje")) + + val credentials = plan.connectionId?.let { + credentialsStore.getCredentials(it, userPreferences.isSandboxMode()) + } ?: return Result.failure(IllegalStateException("Chybi credentials pro plan")) + + val api = exchangeApiFactory.create(credentials) + val result = api.limitSell(plan.crypto, plan.fiat, cryptoAmount, limitPrice) + + return when (result) { + is DcaResult.Success -> { + val tx = result.transaction.copy( + planId = planId, + connectionId = plan.connectionId + ) + val txId = database.transactionDao().insertTransaction(tx.toEntity()) + resolvePendingTransactionsUseCase() // okamzity poll (edge: instant fill) + Result.success(txId) + } + is DcaResult.Failure -> Result.failure( + IllegalStateException("${result.reason}: ${result.message}") + ) + } + } +} +``` + +- [ ] **Krok 2: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 3: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLimitSellUseCase.kt +git commit -m "feat(sell): add PlaceLimitSellUseCase" +``` + +--- + +### Task 14: CancelSellOrderUseCase + +**Soubory:** +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CancelSellOrderUseCase.kt` + +- [ ] **Krok 1: Vytvorit use case** + +```kotlin +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.domain.model.TransactionStatus +import com.accbot.dca.exchange.ExchangeApiFactory +import java.math.BigDecimal +import javax.inject.Inject + +class CancelSellOrderUseCase @Inject constructor( + private val database: DcaDatabase, + private val credentialsStore: CredentialsStore, + private val exchangeApiFactory: ExchangeApiFactory, + private val userPreferences: UserPreferences, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase +) { + suspend operator fun invoke(txId: Long): Result { + val tx = database.transactionDao().getTransactionById(txId) + ?: return Result.failure(IllegalArgumentException("Transakce $txId neexistuje")) + + val orderId = tx.exchangeOrderId + ?: return Result.failure(IllegalStateException("Transakce nema exchangeOrderId")) + + val credentials = tx.connectionId?.let { + credentialsStore.getCredentials(it, userPreferences.isSandboxMode()) + } ?: return Result.failure(IllegalStateException("Chybi credentials")) + + val api = exchangeApiFactory.create(credentials) + val cancelResult = api.cancelOrder(orderId, tx.crypto, tx.fiat) + + return if (cancelResult.isSuccess) { + val newStatus = if (tx.cryptoAmount > BigDecimal.ZERO) + TransactionStatus.PARTIAL else TransactionStatus.FAILED + database.transactionDao().updateResolvedTransaction( + id = txId, + newStatus = newStatus, + cryptoAmount = tx.cryptoAmount, + fiatAmount = tx.fiatAmount, + price = tx.price, + fee = tx.fee + ) + Result.success(Unit) + } else { + resolvePendingTransactionsUseCase() // mozna se mezitim zfilovalo + cancelResult + } + } +} +``` + +- [ ] **Krok 2: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 3: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CancelSellOrderUseCase.kt +git commit -m "feat(sell): add CancelSellOrderUseCase" +``` + +--- + +### Task 15: CalculatePlanPnLUseCase + +**Soubory:** +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/domain/model/PlanPnL.kt` +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanPnLUseCase.kt` + +- [ ] **Krok 1: Vytvorit PlanPnL data class** + +`PlanPnL.kt`: + +```kotlin +package com.accbot.dca.domain.model + +import java.math.BigDecimal + +data class PlanPnL( + val totalBoughtFiat: BigDecimal, + val totalBoughtCrypto: BigDecimal, + val totalSoldFiat: BigDecimal, + val totalSoldCrypto: BigDecimal, + val currentCryptoHeld: BigDecimal, + val avgBuyPrice: BigDecimal?, + val currentValueFiat: BigDecimal?, + val realizedPnL: BigDecimal?, + val unrealizedPnL: BigDecimal?, + val netPnL: BigDecimal?, + val targetProgressPct: Double? +) +``` + +- [ ] **Krok 2: Vytvorit CalculatePlanPnLUseCase** + +`CalculatePlanPnLUseCase.kt`: + +```kotlin +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.TransactionSide +import com.accbot.dca.domain.model.PlanPnL +import com.accbot.dca.domain.model.TransactionStatus +import java.math.BigDecimal +import java.math.RoundingMode +import javax.inject.Inject + +class CalculatePlanPnLUseCase @Inject constructor( + private val database: DcaDatabase +) { + suspend operator fun invoke( + planId: Long, + currentMarketPrice: BigDecimal? + ): PlanPnL { + val plan = database.dcaPlanDao().getPlanById(planId) + ?: error("Plan $planId neexistuje") + + val transactions = database.transactionDao() + .getTransactionsByPlanId(planId) + .filter { it.status == TransactionStatus.COMPLETED || it.status == TransactionStatus.PARTIAL } + + val buys = transactions.filter { it.side == TransactionSide.BUY } + val sells = transactions.filter { it.side == TransactionSide.SELL } + + val totalBoughtFiat = buys.sumOf { it.fiatAmount } + val totalBoughtCrypto = buys.sumOf { it.cryptoAmount } + val totalSoldFiat = sells.sumOf { it.fiatAmount } + val totalSoldCrypto = sells.sumOf { it.cryptoAmount } + val currentCryptoHeld = totalBoughtCrypto - totalSoldCrypto + + val avgBuyPrice = if (totalBoughtCrypto > BigDecimal.ZERO) + totalBoughtFiat.divide(totalBoughtCrypto, 8, RoundingMode.HALF_UP) + else null + + val currentValueFiat = currentMarketPrice?.let { currentCryptoHeld * it } + + val realizedPnL = avgBuyPrice?.let { + totalSoldFiat - (totalSoldCrypto * it) + } + + val unrealizedPnL = if (avgBuyPrice != null && currentValueFiat != null) + currentValueFiat - (currentCryptoHeld * avgBuyPrice) + else null + + val netPnL = if (realizedPnL != null && unrealizedPnL != null) + realizedPnL + unrealizedPnL + else null + + val targetProgressPct = if (netPnL != null && plan.targetProfitAmount != null && plan.targetProfitAmount > BigDecimal.ZERO) + netPnL.toDouble() / plan.targetProfitAmount.toDouble() + else null + + return PlanPnL( + totalBoughtFiat, totalBoughtCrypto, + totalSoldFiat, totalSoldCrypto, + currentCryptoHeld, avgBuyPrice, currentValueFiat, + realizedPnL, unrealizedPnL, netPnL, targetProgressPct + ) + } +} +``` + +- [ ] **Krok 3: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/ +git commit -m "feat(sell): add PlanPnL model + CalculatePlanPnLUseCase" +``` + +--- + +### Task 16: ValidateSellOrderUseCase (business validace wizardu) + +**Soubory:** +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt` + +- [ ] **Krok 1: Vytvorit use case** + +```kotlin +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.TransactionSide +import com.accbot.dca.domain.model.TransactionStatus +import java.math.BigDecimal +import javax.inject.Inject + +sealed class SellValidation { + object Ok : SellValidation() + data class HardError(val message: String) : SellValidation() + data class InstantFillInfo(val spot: BigDecimal) : SellValidation() + data class FarFromMarketWarning(val spot: BigDecimal) : SellValidation() +} + +class ValidateSellOrderUseCase @Inject constructor( + private val database: DcaDatabase +) { + suspend operator fun invoke( + planId: Long, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal, + minOrderSize: BigDecimal, + currentSpot: BigDecimal? + ): List { + val result = mutableListOf() + + if (cryptoAmount <= BigDecimal.ZERO) { + result += SellValidation.HardError("Mnozstvi musi byt vetsi nez 0") + return result + } + + if (limitPrice <= BigDecimal.ZERO) { + result += SellValidation.HardError("Limitni cena musi byt vetsi nez 0") + return result + } + + if (cryptoAmount < minOrderSize) { + result += SellValidation.HardError("Minimalni order je $minOrderSize") + } + + val tx = database.transactionDao().getTransactionsByPlanId(planId) + val completedOrPartial = tx.filter { it.status == TransactionStatus.COMPLETED || it.status == TransactionStatus.PARTIAL } + val heldBought = completedOrPartial.filter { it.side == TransactionSide.BUY }.sumOf { it.cryptoAmount } + val heldSold = completedOrPartial.filter { it.side == TransactionSide.SELL }.sumOf { it.cryptoAmount } + val held = heldBought - heldSold + + val openSellsRequested = tx + .filter { it.side == TransactionSide.SELL && it.status in setOf(TransactionStatus.PENDING, TransactionStatus.PARTIAL) } + .sumOf { (it.requestedCryptoAmount ?: BigDecimal.ZERO) - it.cryptoAmount } + + val available = held - openSellsRequested + if (cryptoAmount > available) { + result += SellValidation.HardError("Nemas tolik BTC k dispozici (k dispozici $available)") + } + + if (currentSpot != null) { + if (limitPrice <= currentSpot) { + result += SellValidation.InstantFillInfo(currentSpot) + } + if (limitPrice > currentSpot.multiply(BigDecimal("3"))) { + result += SellValidation.FarFromMarketWarning(currentSpot) + } + } + + if (result.isEmpty()) result += SellValidation.Ok + return result + } +} +``` + +- [ ] **Krok 2: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 3: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt +git commit -m "feat(sell): add ValidateSellOrderUseCase with instant-fill + far-from-market checks" +``` + +--- + +## Faze 4: UserPreferences + periodic polling + +### Task 17: Rozsirit UserPreferences o trading flagy + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt` + +- [ ] **Krok 1: Pridat klice a gettery/settery** + +```kotlin +// Klice (companion object nebo top-level const): +private const val KEY_TRADING_ENABLED = "trading_enabled" +private const val KEY_SELL_POLLING_ENABLED = "sell_polling_enabled" +private const val KEY_SELL_POLLING_FREQUENCY = "sell_polling_frequency" +private const val KEY_SELL_POLLING_CRON = "sell_polling_cron" +private const val KEY_SELL_POLLING_SCHEDULE_CONFIG = "sell_polling_schedule_config" + +// Metody: +fun isTradingEnabled(): Boolean = prefs.getBoolean(KEY_TRADING_ENABLED, false) +fun setTradingEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_TRADING_ENABLED, enabled).apply() } + +fun isPeriodicSellPollingEnabled(): Boolean = prefs.getBoolean(KEY_SELL_POLLING_ENABLED, false) +fun getSellPollingFrequency(): DcaFrequency = + prefs.getString(KEY_SELL_POLLING_FREQUENCY, null)?.let { DcaFrequency.valueOf(it) } ?: DcaFrequency.HOURLY +fun getSellPollingCronExpression(): String? = prefs.getString(KEY_SELL_POLLING_CRON, null) +fun getSellPollingScheduleConfig(): String? = prefs.getString(KEY_SELL_POLLING_SCHEDULE_CONFIG, null) + +fun setPeriodicSellPolling(enabled: Boolean, frequency: DcaFrequency, cron: String?, scheduleConfig: String?) { + prefs.edit() + .putBoolean(KEY_SELL_POLLING_ENABLED, enabled) + .putString(KEY_SELL_POLLING_FREQUENCY, frequency.name) + .putString(KEY_SELL_POLLING_CRON, cron) + .putString(KEY_SELL_POLLING_SCHEDULE_CONFIG, scheduleConfig) + .apply() +} +``` + +- [ ] **Krok 2: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 3: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt +git commit -m "feat(sell): add trading + sell polling flags to UserPreferences" +``` + +--- + +### Task 18: SellPollingWorker + SellPollingScheduler + +**Soubory:** +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/worker/SellPollingWorker.kt` +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/worker/SellPollingScheduler.kt` + +- [ ] **Krok 1: Prozkoumat DcaWorker pattern** + +```bash +head -100 accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt +``` + +Najit jak se schedule vypocitava `nextExecutionAt` z `cronExpression` / `DcaFrequency`. Bude sdilena utility funkce. + +- [ ] **Krok 2: Vytvorit SellPollingWorker** + +```kotlin +package com.accbot.dca.worker + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.domain.usecase.ResolvePendingTransactionsUseCase +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +@HiltWorker +class SellPollingWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val database: DcaDatabase, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + return try { + val openSells = database.transactionDao().countOpenSells() + if (openSells == 0) return Result.success() + + resolvePendingTransactionsUseCase() + Result.success() + } catch (e: Exception) { + Result.retry() + } + } + + companion object { + const val WORK_NAME = "sell_polling" + } +} +``` + +- [ ] **Krok 3: Vytvorit SellPollingScheduler** + +```kotlin +package com.accbot.dca.worker + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.domain.model.DcaFrequency +import com.accbot.dca.domain.util.calculateNextFireTime +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SellPollingScheduler @Inject constructor( + private val workManager: WorkManager, + private val userPreferences: UserPreferences +) { + fun rescheduleIfEnabled() { + if (!userPreferences.isPeriodicSellPollingEnabled()) { + cancel() + return + } + + val frequency = userPreferences.getSellPollingFrequency() + val cronOrConfig = userPreferences.getSellPollingCronExpression() + ?: userPreferences.getSellPollingScheduleConfig() + + val nextFire = calculateNextFireTime(frequency, cronOrConfig, Instant.now()) + val delayMs = (nextFire.toEpochMilli() - System.currentTimeMillis()).coerceAtLeast(0L) + + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(delayMs, java.util.concurrent.TimeUnit.MILLISECONDS) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .build() + + workManager.enqueueUniqueWork( + SellPollingWorker.WORK_NAME, + ExistingWorkPolicy.REPLACE, + request + ) + } + + fun cancel() { + workManager.cancelUniqueWork(SellPollingWorker.WORK_NAME) + } +} +``` + +**Pozn.:** `calculateNextFireTime` existuje nekde v codebase (pouziva ji DcaWorker). Overit `grep -rn "calculateNextFireTime\|fun.*nextFire\|toCronExpression" accbot-android/app/src/main/java/com/accbot/dca/domain/`. Reuse. + +- [ ] **Krok 4: Zaretez rescheduling - po kazdem worker doWork() naplanovat dalsi** + +V `SellPollingWorker` injectovat `SellPollingScheduler`: + +```kotlin +@HiltWorker +class SellPollingWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val database: DcaDatabase, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase, + private val sellPollingScheduler: SellPollingScheduler +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + return try { + val openSells = database.transactionDao().countOpenSells() + if (openSells > 0) { + resolvePendingTransactionsUseCase() + } + sellPollingScheduler.rescheduleIfEnabled() // naplanovat dalsi spusteni + Result.success() + } catch (e: Exception) { + sellPollingScheduler.rescheduleIfEnabled() // naplanovat i pri failu + Result.retry() + } + } + + companion object { const val WORK_NAME = "sell_polling" } +} +``` + +- [ ] **Krok 5: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 6: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/worker/ +git commit -m "feat(sell): add SellPollingWorker + SellPollingScheduler" +``` + +--- + +### Task 19: ProcessLifecycle observer pro onResume polling + +**Soubory:** +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/AppLifecycleObserver.kt` (nebo do existujiciho Application tridy) + +- [ ] **Krok 1: Najit Application tridu** + +```bash +grep -rn "class.*Application\|@HiltAndroidApp" accbot-android/app/src/main/java/com/accbot/dca/ | head -5 +``` + +- [ ] **Krok 2: Vytvorit lifecycle observer** + +V `AccBotApplication.kt` (nebo jak se jmenuje): + +```kotlin +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import com.accbot.dca.domain.usecase.ResolvePendingTransactionsUseCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltAndroidApp +class AccBotApplication : Application() { + + @Inject lateinit var resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase + + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + override fun onCreate() { + super.onCreate() + // ... existujici ... + + ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + appScope.launch { + try { + resolvePendingTransactionsUseCase() + } catch (e: Exception) { + android.util.Log.w("AppLifecycle", "Polling on app start failed", e) + } + } + } + }) + } +} +``` + +- [ ] **Krok 3: Build check** + +```bash +cd accbot-android && ./gradlew assembleDebug +``` + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/ +git commit -m "feat(sell): trigger pending tx resolution on app foreground" +``` + +--- + +## Faze 5: UI - Settings + +### Task 20: Rozsirit SettingsScreen o Pokrocile sekci + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsScreen.kt` +- Upravit: ViewModel pro SettingsScreen (grep pro `SettingsViewModel`) + +- [ ] **Krok 1: Najit SettingsViewModel** + +```bash +grep -rn "class SettingsViewModel" accbot-android/app/src/main/java/com/accbot/dca/ +``` + +- [ ] **Krok 2: Pridat state pro trading toggle a periodic polling** + +Do `SettingsUiState`: + +```kotlin +val tradingEnabled: Boolean = false, +val periodicSellPollingEnabled: Boolean = false, +val sellPollingFrequency: DcaFrequency = DcaFrequency.HOURLY, +val sellPollingScheduleState: ScheduleBuilderState = ScheduleBuilderState() +``` + +- [ ] **Krok 3: Pridat ViewModel akce** + +```kotlin +fun setTradingEnabled(enabled: Boolean) { + userPreferences.setTradingEnabled(enabled) + if (!enabled) { + userPreferences.setPeriodicSellPolling(false, DcaFrequency.HOURLY, null, null) + sellPollingScheduler.cancel() + } + refreshState() +} + +fun setPeriodicSellPolling( + enabled: Boolean, + frequency: DcaFrequency, + cron: String?, + scheduleConfig: String? +) { + userPreferences.setPeriodicSellPolling(enabled, frequency, cron, scheduleConfig) + if (enabled) sellPollingScheduler.rescheduleIfEnabled() + else sellPollingScheduler.cancel() + refreshState() +} +``` + +- [ ] **Krok 4: Pridat Compose sekci do SettingsScreen** + +Najit konec existujiciho settings formu a pridat: + +```kotlin +SettingsSection(title = "Pokrocile") { + SwitchRow( + title = "Povolit prodeje", + subtitle = "Umozni u vybranych planu zadavat limitni prodejni prikazy a sledovat P&L.", + checked = uiState.tradingEnabled, + onCheckedChange = viewModel::setTradingEnabled + ) + + if (uiState.tradingEnabled) { + Divider() + + SwitchRow( + title = "Kontrolovat sell ordery na pozadi", + subtitle = "Periodicka kontrola stavu orderu. Zvysuje spotrebu baterie.", + checked = uiState.periodicSellPollingEnabled, + onCheckedChange = { enabled -> + viewModel.setPeriodicSellPolling( + enabled = enabled, + frequency = uiState.sellPollingFrequency, + cron = uiState.sellPollingScheduleState.toCronExpression().takeIf { uiState.sellPollingFrequency == DcaFrequency.CUSTOM }, + scheduleConfig = null + ) + } + ) + + if (uiState.periodicSellPollingEnabled) { + // Reuse schedule builder z AddPlanScreen + FrequencyPickerAndScheduleBuilder( + frequency = uiState.sellPollingFrequency, + state = uiState.sellPollingScheduleState, + onFrequencyChange = { freq -> + viewModel.setPeriodicSellPolling( + enabled = true, + frequency = freq, + cron = null, + scheduleConfig = null + ) + }, + onStateChange = { state -> + // Uloz state pro CUSTOM/DAILY/WEEKLY + } + ) + + Text( + text = "Caste kontroly zvysuji spotrebu baterie a pocitaji se do API limitu burzy.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + } + } +} +``` + +- [ ] **Krok 5: Pokud neexistuje sdilena komponenta FrequencyPickerAndScheduleBuilder** + +Extrahovat existujici Compose logiku z `AddPlanScreen.kt` (kde je ScheduleBuilder) do sdilene komponenty `accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ScheduleBuilder.kt`. Pouzit stejny Compose kod v SettingsScreen. + +- [ ] **Krok 6: Build check + instalace** + +```bash +cd accbot-android && ./gradlew assembleDebug && ./gradlew installDebug +``` + +Otevrit app, jit do Settings, overit ze: +- Toggle "Povolit prodeje" funguje +- Pri ON se odkryje "Kontrolovat na pozadi" +- Pri zapnuti periodic se odkryje frequency picker + schedule builder + +- [ ] **Krok 7: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/ +git commit -m "feat(sell): add Advanced section to SettingsScreen with trading + polling toggles" +``` + +--- + +## Faze 6: UI - Plan creation/edit + +### Task 21: Rozsirit AddPlanScreen o sell sekci (gated by global trading) + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt` +- Upravit: AddPlan ViewModel + +- [ ] **Krok 1: Injektovat UserPreferences do AddPlanViewModel a expose trading flag** + +```kotlin +val tradingEnabled: Boolean = userPreferences.isTradingEnabled() +``` + +- [ ] **Krok 2: Pridat state pro allowSells + targetProfitAmount** + +```kotlin +val allowSells: Boolean = false, +val targetProfitAmount: String = "", // raw input +val targetProfitAmountError: String? = null +``` + +Validace `targetProfitAmount`: musi byt prazdny nebo kladne cislo parsovatelne BigDecimal. + +- [ ] **Krok 3: Update CreateDcaPlanUseCase (pokud je potreba)** + +Zkontrolovat `CreateDcaPlanUseCase`, pokud akceptuje `DcaPlan`, automaticky dostane nova pole. Pokud ma explicitni parametry, pridat: + +```kotlin +suspend operator fun invoke( + // ... existujici ... + allowSells: Boolean = false, + targetProfitAmount: BigDecimal? = null +): Result +``` + +- [ ] **Krok 4: Pridat Compose sekci v AddPlanScreen** + +Na konec formu, pred submit button, pridat: + +```kotlin +if (uiState.tradingEnabled) { + SectionHeader(text = "Prodeje (volitelne)") + + SwitchRow( + title = "Povolit prodeje pro tento plan", + subtitle = null, + checked = uiState.allowSells, + onCheckedChange = viewModel::setAllowSells + ) + + if (uiState.allowSells) { + OutlinedTextField( + value = uiState.targetProfitAmount, + onValueChange = viewModel::setTargetProfitAmount, + label = { Text("Cil zisku (volitelne, v ${uiState.fiat})") }, + supportingText = { + Text("Plan-detail zobrazi progress bar k tomuto cili.") + }, + isError = uiState.targetProfitAmountError != null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth() + ) + } +} +``` + +- [ ] **Krok 5: Wire submit - predat nova pole do CreateDcaPlanUseCase** + +```kotlin +createDcaPlanUseCase( + // ... existujici ... + allowSells = uiState.allowSells, + targetProfitAmount = uiState.targetProfitAmount.takeIf { it.isNotBlank() }?.let { BigDecimal(it) } +) +``` + +- [ ] **Krok 6: Build check + manualni test** + +```bash +cd accbot-android && ./gradlew installDebug +``` + +Settings -> zapnout Povolit prodeje. AddPlan screen -> overit ze se zobrazi Prodeje sekce. Vypnout trading -> sekce mizi. + +- [ ] **Krok 7: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt +git add accbot-android/app/src/main/java/com/accbot/dca/ +git commit -m "feat(sell): add Prodeje section to AddPlanScreen (gated by trading_enabled)" +``` + +--- + +### Task 22: Rozsirit EditPlanScreen o sell sekci + confirm dialog + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanScreen.kt` +- Upravit: EditPlan ViewModel + +- [ ] **Krok 1: Stejna logika jako AddPlanScreen - pridat state + sekci** + +Identicke kroky jako Task 21 Kroky 1-4, jen v EditPlanScreen kontextu. Nacita existujici hodnoty `plan.allowSells`, `plan.targetProfitAmount` do state. + +- [ ] **Krok 2: Pridat confirm dialog pri vypnuti allowSells pokud jsou open ordery** + +V onClick `Ulozit` (nebo onChange allowSells toggle off): + +```kotlin +fun onToggleAllowSells(newValue: Boolean) { + if (!newValue && uiState.currentAllowSells) { + viewModelScope.launch { + val openSells = database.transactionDao().observeOpenSellsForPlan(planId).first().size + if (openSells > 0) { + _uiState.update { it.copy(showDisableSellsDialog = openSells) } + } else { + _uiState.update { it.copy(allowSells = false) } + } + } + } else { + _uiState.update { it.copy(allowSells = newValue) } + } +} +``` + +Compose: + +```kotlin +uiState.showDisableSellsDialog?.let { count -> + AlertDialog( + onDismissRequest = { viewModel.dismissDisableSellsDialog() }, + title = { Text("Vypnout prodeje?") }, + text = { Text("Mas $count otevrenych sell orderu. Vypnutim prodeju se skryje sell sekce, ale ordery na burze zustavaji. Musis je zrusit rucne pres burzu, nebo zapnutim prodeju a kliknutim Cancel.") }, + confirmButton = { + TextButton(onClick = { viewModel.confirmDisableSells() }) { + Text("Vypnout") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissDisableSellsDialog() }) { + Text("Zrusit") + } + } + ) +} +``` + +- [ ] **Krok 3: Build check + manualni test** + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanScreen.kt +git commit -m "feat(sell): add Prodeje section + disable confirm dialog to EditPlanScreen" +``` + +--- + +## Faze 7: UI - Plan detail + sell wizard + +### Task 23: Plan detail - P&L card + open orders list + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt` +- Upravit: PlanDetails ViewModel +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/PnLCard.kt` +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/OpenSellsList.kt` + +- [ ] **Krok 1: ViewModel expose PlanPnL + open sells** + +```kotlin +val planPnL: StateFlow = combine( + transactionFlow, + spotPriceFlow +) { txs, spot -> + calculatePlanPnLUseCase(planId, spot) +}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + +val openSells: StateFlow> = + transactionDao.observeOpenSellsForPlan(planId) + .map { list -> list.map { it.toDomain() } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) +``` + +- [ ] **Krok 2: Vytvorit PnLCard composable** + +```kotlin +@Composable +fun PnLCard( + pnl: PlanPnL, + fiat: String, + targetAmount: BigDecimal?, + modifier: Modifier = Modifier +) { + Card(modifier.fillMaxWidth().padding(16.dp)) { + Column(Modifier.padding(16.dp)) { + Text("P&L", style = MaterialTheme.typography.titleMedium) + PnLRow("Drzeno:", "${pnl.currentCryptoHeld.stripTrailingZeros().toPlainString()} BTC") + pnl.currentValueFiat?.let { + PnLRow(" = hodnota:", "${formatFiat(it, fiat)}") + } + pnl.avgBuyPrice?.let { + PnLRow("Prum. nakup:", "${formatFiat(it, fiat)}") + } + pnl.realizedPnL?.let { + PnLRow("Realizovany:", formatPnL(it, fiat), pnlColor(it)) + } ?: PnLRow("Realizovany:", "-") + + pnl.unrealizedPnL?.let { + PnLRow("Nerealizovany:", formatPnL(it, fiat), pnlColor(it)) + } ?: PnLRow("Nerealizovany:", "-") + + pnl.netPnL?.let { + PnLRow("Net:", formatPnL(it, fiat), pnlColor(it), bold = true) + } + + if (targetAmount != null && pnl.targetProgressPct != null) { + Spacer(Modifier.height(8.dp)) + LinearProgressIndicator( + progress = pnl.targetProgressPct.toFloat().coerceIn(0f, 1f), + modifier = Modifier.fillMaxWidth() + ) + Text( + "Cil: ${formatFiat(targetAmount, fiat)} (${(pnl.targetProgressPct * 100).toInt()}%)", + style = MaterialTheme.typography.bodySmall + ) + } + } + } +} + +@Composable +private fun PnLRow(label: String, value: String, color: Color? = null, bold: Boolean = false) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Text( + value, + style = MaterialTheme.typography.bodyMedium, + color = color ?: LocalContentColor.current, + fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal + ) + } +} + +private fun pnlColor(value: BigDecimal): Color = when { + value > BigDecimal.ZERO -> Color(0xFF2E7D32) // green + value < BigDecimal.ZERO -> Color(0xFFC62828) // red + else -> Color.Unspecified +} + +private fun formatPnL(value: BigDecimal, fiat: String): String = + (if (value >= BigDecimal.ZERO) "+" else "") + formatFiat(value, fiat) +``` + +- [ ] **Krok 3: Vytvorit OpenSellsList composable** + +```kotlin +@Composable +fun OpenSellsList( + openSells: List, + onCancelClick: (Long) -> Unit, + modifier: Modifier = Modifier +) { + if (openSells.isEmpty()) return + + Card(modifier.fillMaxWidth().padding(16.dp)) { + Column(Modifier.padding(16.dp)) { + Text("Otevrene sell ordery (${openSells.size})", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + openSells.forEach { tx -> + OpenSellRow(tx, onCancelClick) + } + } + } +} + +@Composable +private fun OpenSellRow(tx: Transaction, onCancelClick: (Long) -> Unit) { + val requested = tx.requestedCryptoAmount ?: BigDecimal.ZERO + val filled = tx.cryptoAmount + val progressPct = if (requested > BigDecimal.ZERO) + filled.divide(requested, 4, RoundingMode.HALF_UP).multiply(BigDecimal(100)).toInt() else 0 + + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("${requested.toPlainString()} ${tx.crypto} @ ${tx.limitPrice?.toPlainString() ?: "-"} ${tx.fiat}") + if (tx.status == TransactionStatus.PARTIAL) { + Text("Partial: $progressPct% (${filled.toPlainString()} / ${requested.toPlainString()})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary) + } else { + Text("Pending", style = MaterialTheme.typography.bodySmall) + } + } + IconButton(onClick = { onCancelClick(tx.id) }) { + Icon(Icons.Default.Close, contentDescription = "Zrusit order") + } + } +} +``` + +- [ ] **Krok 4: Vlozit komponenty do PlanDetailsScreen** + +V mistem layoutu (mezi buy info a transaction history): + +```kotlin +val sellUiVisible = remember(plan, userPrefs, exchangeApi) { + plan.allowSells && userPrefs.isTradingEnabled() && exchangeApi.supportsLimitSell +} + +if (sellUiVisible) { + pnl?.let { PnLCard(it, plan.fiat, plan.targetProfitAmount) } + OpenSellsList(openSells, onCancelClick = { viewModel.cancelSell(it) }) + Button( + onClick = { viewModel.openSellWizard() }, + enabled = (pnl?.currentCryptoHeld ?: BigDecimal.ZERO) > BigDecimal.ZERO + ) { + Text("+ Vytvorit prodejni prikaz") + } +} +``` + +- [ ] **Krok 5: Wire cancelSell akci ve ViewModelu** + +```kotlin +fun cancelSell(txId: Long) = viewModelScope.launch { + val result = cancelSellOrderUseCase(txId) + if (result.isFailure) { + _snackbar.emit("Zruseni orderu selhalo: ${result.exceptionOrNull()?.message}") + } +} +``` + +- [ ] **Krok 6: Build check + manualni test** + +```bash +cd accbot-android && ./gradlew installDebug +``` + +Otevrit plan-detail planu s `allowSells=true`. Overit: +- P&L card se zobrazuje (i s null hodnotami jako "-") +- Open sells list je prazdny (zatim nejsou sell transakce) +- Button "Vytvorit prodejni prikaz" je disabled pokud held=0, inak enabled + +- [ ] **Krok 7: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/ +git commit -m "feat(sell): add P&L card + open sells list to PlanDetailsScreen" +``` + +--- + +### Task 24: Sell wizard Krok 1 - zadani objednavky + +**Soubory:** +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt` +- Vytvorit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt` + +- [ ] **Krok 1: Vytvorit SellWizardViewModel** + +```kotlin +@HiltViewModel +class SellWizardViewModel @Inject constructor( + private val validateUseCase: ValidateSellOrderUseCase, + private val placeSellUseCase: PlaceLimitSellUseCase, + private val calculatePnLUseCase: CalculatePlanPnLUseCase, + private val database: DcaDatabase, + private val credentialsStore: CredentialsStore, + private val exchangeApiFactory: ExchangeApiFactory, + private val userPreferences: UserPreferences +) : ViewModel() { + + data class UiState( + val planId: Long = 0, + val crypto: String = "", + val fiat: String = "", + val held: BigDecimal = BigDecimal.ZERO, + val spotPrice: BigDecimal? = null, + val avgBuyPrice: BigDecimal? = null, + val amountInput: String = "", // raw, v crypto + val priceInput: String = "", // raw, ve fiatu + val minOrderSize: BigDecimal = BigDecimal("0.0001"), + val validations: List = emptyList(), + val step: WizardStep = WizardStep.INPUT, + val submitting: Boolean = false, + val submitError: String? = null, + val showTimeoutDialog: Boolean = false + ) { + val canProceed: Boolean + get() = validations.none { it is SellValidation.HardError } && + amountInput.isNotBlank() && priceInput.isNotBlank() + } + + enum class WizardStep { INPUT, CONFIRM } + + // setters: setAmount, setPrice, chipActions (25/50/75/all, spot/breakeven/+10/+25) + // validate() - re-runs validateUseCase on every input change + // proceedToConfirm() - step = CONFIRM + // submit() - calls placeSellUseCase + // back() - step = INPUT +} +``` + +- [ ] **Krok 2: Vytvorit bottom sheet UI (zadani)** + +```kotlin +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SellWizardBottomSheet( + planId: Long, + onDismiss: () -> Unit, + viewModel: SellWizardViewModel = hiltViewModel() +) { + LaunchedEffect(planId) { viewModel.init(planId) } + + val state by viewModel.uiState.collectAsStateWithLifecycle() + ModalBottomSheet( + onDismissRequest = onDismiss, + modifier = Modifier.fillMaxHeight(0.95f) + ) { + when (state.step) { + SellWizardViewModel.WizardStep.INPUT -> SellInputStep(state, viewModel, onDismiss) + SellWizardViewModel.WizardStep.CONFIRM -> SellConfirmStep(state, viewModel) + } + } +} + +@Composable +private fun SellInputStep( + state: SellWizardViewModel.UiState, + vm: SellWizardViewModel, + onDismiss: () -> Unit +) { + Column(Modifier.padding(16.dp).verticalScroll(rememberScrollState())) { + TopAppBar( + title = { Text("Limit sell ${state.crypto}/${state.fiat}") }, + navigationIcon = { IconButton(onDismiss) { Icon(Icons.Default.Close, null) } } + ) + + InfoRow("Aktualni cena:", state.spotPrice?.toPlainString() ?: "-") + InfoRow("Prum. nakup:", state.avgBuyPrice?.toPlainString() ?: "-") + InfoRow("K dispozici:", "${state.held.toPlainString()} ${state.crypto}") + + SectionHeader("Mnozstvi") + OutlinedTextField( + value = state.amountInput, + onValueChange = vm::setAmount, + trailingIcon = { Text(state.crypto) }, + modifier = Modifier.fillMaxWidth() + ) + Row { listOf(25, 50, 75, 100).forEach { pct -> + AssistChip(onClick = { vm.setAmountPct(pct) }, label = { Text(if (pct == 100) "Vse" else "$pct%") }) + } } + + SectionHeader("Limitni cena") + OutlinedTextField( + value = state.priceInput, + onValueChange = vm::setPrice, + trailingIcon = { Text(state.fiat) }, + modifier = Modifier.fillMaxWidth() + ) + Row { + AssistChip(onClick = vm::setPriceSpot, label = { Text("Trzni") }) + AssistChip(onClick = vm::setPriceBreakeven, label = { Text("Breakeven") }) + AssistChip(onClick = { vm.setPricePct(10) }, label = { Text("+10%") }) + AssistChip(onClick = { vm.setPricePct(25) }, label = { Text("+25%") }) + } + + Spacer(Modifier.height(16.dp)) + SectionHeader("Souhrn") + val amountBD = state.amountInput.toBigDecimalOrNull() ?: BigDecimal.ZERO + val priceBD = state.priceInput.toBigDecimalOrNull() ?: BigDecimal.ZERO + InfoRow("Ziskate:", "${(amountBD * priceBD).toPlainString()} ${state.fiat}") + state.avgBuyPrice?.let { avg -> + val profit = (priceBD - avg) * amountBD + InfoRow("Zisk vs prum:", formatPnL(profit, state.fiat), pnlColor(profit)) + } + + // validation messages + state.validations.forEach { v -> + when (v) { + is SellValidation.HardError -> + Text(v.message, color = MaterialTheme.colorScheme.error) + is SellValidation.InstantFillInfo -> + InfoBanner("Prodej probehne okamzite. Limitni cena je pod aktualni trzni (${v.spot.toPlainString()} ${state.fiat}). Prikaz se zfilluje ihned za nejvyssi nabidku na burze.") + is SellValidation.FarFromMarketWarning -> + WarningBanner("Cena vysoko nad trhem - prodej se nemusi zfillovat dlouho.") + is SellValidation.Ok -> { /* noop */ } + } + } + + Button( + onClick = vm::proceedToConfirm, + enabled = state.canProceed, + modifier = Modifier.fillMaxWidth() + ) { Text("Pokracovat") } + } +} +``` + +- [ ] **Krok 3: Implementovat chip actions ve ViewModelu** + +```kotlin +fun setAmountPct(pct: Int) { + val available = state.held - sumOpenSellRequested(state.planId) + val amount = available.multiply(BigDecimal(pct)).divide(BigDecimal(100), 8, RoundingMode.HALF_UP) + setAmount(amount.stripTrailingZeros().toPlainString()) +} + +fun setPriceSpot() = state.spotPrice?.let { setPrice(it.toPlainString()) } +fun setPriceBreakeven() = state.avgBuyPrice?.let { setPrice(it.toPlainString()) } +fun setPricePct(pct: Int) = state.avgBuyPrice?.let { avg -> + val price = avg.multiply(BigDecimal("1.${pct.toString().padStart(2, '0')}")) + setPrice(price.setScale(2, RoundingMode.HALF_UP).toPlainString()) +} +``` + +- [ ] **Krok 4: Build check + manualni test** + +```bash +cd accbot-android && ./gradlew installDebug +``` + +Overit ze bottom sheet otevre, inputy funguji, chipy pocitaji spravne, validace se zobrazuji (hlavne instant-fill banner kdyz limit < spot). + +- [ ] **Krok 5: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/ +git commit -m "feat(sell): add SellWizardBottomSheet Step 1 (input)" +``` + +--- + +### Task 25: Sell wizard Krok 2 - potvrzeni + submit + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt` +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt` + +- [ ] **Krok 1: Pridat SellConfirmStep composable** + +```kotlin +@Composable +private fun SellConfirmStep( + state: SellWizardViewModel.UiState, + vm: SellWizardViewModel +) { + val amountBD = state.amountInput.toBigDecimalOrNull() ?: BigDecimal.ZERO + val priceBD = state.priceInput.toBigDecimalOrNull() ?: BigDecimal.ZERO + + Column(Modifier.padding(16.dp)) { + TopAppBar( + title = { Text("Potvrdit prodej") }, + navigationIcon = { IconButton(vm::back) { Icon(Icons.Default.ArrowBack, null) } } + ) + + SummaryRow("Burza:", state.exchangeName) + SummaryRow("Plan:", state.planName) + SummaryRow("Side:", "PRODEJ") + SummaryRow("Mnozstvi:", "${amountBD.toPlainString()} ${state.crypto}") + SummaryRow("Limitni cena:", "${priceBD.toPlainString()} ${state.fiat}") + SummaryRow("Ziskate:", "${(amountBD * priceBD).toPlainString()} ${state.fiat}") + + WarningBanner("Tato akce odesle prikaz na ${state.exchangeName} a nelze ji vratit. Prikaz lze pote zrusit, dokud neni castecne/celkem zfillovan.") + + state.submitError?.let { err -> + Text(err, color = MaterialTheme.colorScheme.error) + } + + Row { + OutlinedButton(onClick = vm::back, enabled = !state.submitting) { Text("Zpet") } + Spacer(Modifier.width(8.dp)) + Button( + onClick = { vm.submit() }, + enabled = !state.submitting + ) { + if (state.submitting) CircularProgressIndicator() else Text("Odeslat") + } + } + } + + if (state.showTimeoutDialog) { + AlertDialog( + onDismissRequest = vm::dismissTimeoutDialog, + title = { Text("Nelze overit stav prikazu") }, + text = { Text("Spojeni s burzou selhalo. Zkontroluj otevrene ordery na burze pres web a v pripade potreby zrus duplicitu.") }, + confirmButton = { Button(onClick = vm::dismissTimeoutDialog) { Text("OK") } } + ) + } +} +``` + +- [ ] **Krok 2: Implementovat submit ve ViewModelu** + +```kotlin +fun submit() = viewModelScope.launch { + _uiState.update { it.copy(submitting = true, submitError = null) } + + try { + val amount = state.amountInput.toBigDecimal() + val price = state.priceInput.toBigDecimal() + + val result = withTimeoutOrNull(10_000L) { + placeSellUseCase(state.planId, amount, price) + } + + when { + result == null -> { + _uiState.update { it.copy(submitting = false, showTimeoutDialog = true) } + } + result.isSuccess -> { + _navEvents.emit(NavEvent.Dismiss) + _snackbar.emit("Prikaz vytvoren") + } + result.isFailure -> { + val msg = result.exceptionOrNull()?.message ?: "Neznama chyba" + _uiState.update { it.copy(submitting = false, submitError = msg) } + } + } + } catch (e: Exception) { + _uiState.update { it.copy(submitting = false, submitError = e.message ?: "Neznama chyba") } + } +} + +fun dismissTimeoutDialog() { + _uiState.update { it.copy(showTimeoutDialog = false) } +} +``` + +- [ ] **Krok 3: Wire bottom sheet ve Plan detail screen** + +V PlanDetailsScreen: + +```kotlin +var sellWizardOpen by rememberSaveable { mutableStateOf(false) } + +if (sellWizardOpen) { + SellWizardBottomSheet( + planId = planId, + onDismiss = { sellWizardOpen = false } + ) +} + +Button(onClick = { sellWizardOpen = true }) { Text("+ Vytvorit prodejni prikaz") } +``` + +- [ ] **Krok 4: Build check + manualni sandbox test** + +```bash +cd accbot-android && ./gradlew installDebug +``` + +Sandbox mode, plan s allowSells a koupene BTC. Otevrit wizard, zadat 0.0001 BTC @ 1 200 000. Pokracovat. Potvrdit. Overit ze: +- API call prosel (logy) +- Transakce v DB ma status=PENDING, side=SELL, requestedCryptoAmount=0.0001 +- Bottom sheet se zavrel +- Plan-detail ukazuje open order + +- [ ] **Krok 5: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/ +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt +git commit -m "feat(sell): add SellWizardBottomSheet Step 2 (confirm + submit) + wire into plan-detail" +``` + +--- + +## Faze 8: UI - Chart, History, Portfolio, Dashboard + +### Task 26: Chart sell markery + +**Soubory:** +- Upravit: existujici chart komponenta na plan-detail (grep `PlanDetailChart\|chart` v presentation/screens/plans/) + +- [ ] **Krok 1: Najit chart komponentu** + +```bash +grep -rn "Canvas\|drawLine\|Chart" accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/ +``` + +- [ ] **Krok 2: Rozsirit chart o BUY/SELL markery** + +Pro kazdou transakci s `status IN (COMPLETED, PARTIAL)`: + +```kotlin +// v Canvas drawScope: +transactions.filter { it.status in setOf(TransactionStatus.COMPLETED, TransactionStatus.PARTIAL) }.forEach { tx -> + val x = xForTime(tx.executedAt) + val y = yForValue(tx.price) // nebo y = size.height - 20.dp.toPx() pro fixed bottom axis + + val color = when (tx.side) { + TransactionSide.BUY -> Color(0xFF2E7D32) + TransactionSide.SELL -> Color(0xFFC62828) + } + val sizePx = 6.dp.toPx() + + val path = androidx.compose.ui.graphics.Path().apply { + if (tx.side == TransactionSide.BUY) { + // Trojuhelnik nahoru ^ + moveTo(x, y - sizePx) + lineTo(x - sizePx, y + sizePx) + lineTo(x + sizePx, y + sizePx) + } else { + // Trojuhelnik dolu v + moveTo(x, y + sizePx) + lineTo(x - sizePx, y - sizePx) + lineTo(x + sizePx, y - sizePx) + } + close() + } + drawPath(path, color) +} +``` + +- [ ] **Krok 3: Tooltip na tap** + +Rozsirit existujici tap handler aby detect klik na marker a zobrazit tooltip se detaily tx (mnozstvi, cena, status, side). + +- [ ] **Krok 4: Build check + manualni test** + +Test: plan s aspon 1 COMPLETED buy a 1 COMPLETED sell -> v chartu vidim zeleny trojuhelnik nahoru a cerveny dolu. + +- [ ] **Krok 5: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/ +git commit -m "feat(sell): add BUY/SELL markers to plan chart" +``` + +--- + +### Task 27: HistoryScreen - BUY/SELL icons + filter + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt` + +- [ ] **Krok 1: Pridat filter chip** + +```kotlin +enum class HistoryFilter { ALL, BUYS, SELLS, PENDING } + +var filter by rememberSaveable { mutableStateOf(HistoryFilter.ALL) } + +Row { + FilterChip(filter == HistoryFilter.ALL, { filter = HistoryFilter.ALL }, { Text("Vse") }) + FilterChip(filter == HistoryFilter.BUYS, { filter = HistoryFilter.BUYS }, { Text("Nakupy") }) + FilterChip(filter == HistoryFilter.SELLS, { filter = HistoryFilter.SELLS }, { Text("Prodeje") }) + FilterChip(filter == HistoryFilter.PENDING, { filter = HistoryFilter.PENDING }, { Text("Pending") }) +} + +val filtered = transactions.filter { tx -> + when (filter) { + HistoryFilter.ALL -> true + HistoryFilter.BUYS -> tx.side == TransactionSide.BUY + HistoryFilter.SELLS -> tx.side == TransactionSide.SELL + HistoryFilter.PENDING -> tx.status in setOf(TransactionStatus.PENDING, TransactionStatus.PARTIAL) + } +} +``` + +- [ ] **Krok 2: Upravit item rendering** + +```kotlin +@Composable +fun TransactionRow(tx: Transaction) { + val (icon, color, sign) = when (tx.side) { + TransactionSide.BUY -> Triple(Icons.Default.ArrowDownward, Color(0xFF2E7D32), "+") + TransactionSide.SELL -> Triple(Icons.Default.ArrowUpward, Color(0xFFC62828), "-") + } + Row { + Icon(icon, contentDescription = null, tint = color) + Column { + Text("${sign}${tx.cryptoAmount} ${tx.crypto}") + Text("${if (tx.side == TransactionSide.BUY) "-" else "+"}${tx.fiatAmount} ${tx.fiat}") + } + } +} +``` + +- [ ] **Krok 3: Build check + manualni test** + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt +git commit -m "feat(sell): add BUY/SELL icons + filter chips to HistoryScreen" +``` + +--- + +### Task 28: TransactionDetailsScreen - sell-specific fields + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsScreen.kt` + +- [ ] **Krok 1: Pridat rendering pro SELL pole** + +```kotlin +if (tx.side == TransactionSide.SELL) { + DetailRow("Limitni cena:", tx.limitPrice?.toPlainString() ?: "-") + val requested = tx.requestedCryptoAmount ?: BigDecimal.ZERO + DetailRow("Vyplneno:", "${tx.cryptoAmount} / $requested ${tx.crypto} (${progressPct}%)") + tx.price?.let { DetailRow("Avg fill price:", it.toPlainString()) } + + if (tx.status in setOf(TransactionStatus.PENDING, TransactionStatus.PARTIAL)) { + Button(onClick = { viewModel.cancelOrder(tx.id) }) { + Text("Zrusit order") + } + } +} +``` + +- [ ] **Krok 2: Wire cancel ve ViewModelu** + +```kotlin +fun cancelOrder(txId: Long) = viewModelScope.launch { + cancelSellOrderUseCase(txId).onFailure { e -> + _snackbar.emit("Zruseni selhalo: ${e.message}") + } +} +``` + +- [ ] **Krok 3: Build check + manualni test** + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/ +git commit -m "feat(sell): add sell-specific fields + cancel to TransactionDetailsScreen" +``` + +--- + +### Task 29: PortfolioScreen - realized + net P&L + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt` +- Upravit: PortfolioViewModel + CalculatePortfolioUseCase + +- [ ] **Krok 1: Pridat realized + net P&L do CalculatePortfolioUseCase** + +Najit kde se pocita portfolio summary (`CalculatePortfolioUseCase` nebo inline v ViewModelu). Pridat: + +```kotlin +val totalRealizedFiat = completedOrPartialTxs + .filter { it.side == TransactionSide.SELL } + .sumOf { it.fiatAmount } + +val currentHeldValueFiat = /* existing calc */ +val totalInvestedFiat = completedOrPartialTxs + .filter { it.side == TransactionSide.BUY } + .sumOf { it.fiatAmount } + +val netPnLFiat = currentHeldValueFiat + totalRealizedFiat - totalInvestedFiat +``` + +- [ ] **Krok 2: Expose v UiState (gated by isTradingEnabled)** + +```kotlin +val totalRealized: BigDecimal = BigDecimal.ZERO, +val netPnL: BigDecimal? = null, +val showTradingMetrics: Boolean = false // = userPreferences.isTradingEnabled() +``` + +- [ ] **Krok 3: Render v Compose** + +V PortfolioScreen: + +```kotlin +if (uiState.showTradingMetrics && uiState.totalRealized > BigDecimal.ZERO) { + SummaryRow("Celkem realizovano:", "${uiState.totalRealized} ${uiState.fiat}") +} +uiState.netPnL?.takeIf { uiState.showTradingMetrics }?.let { + SummaryRow("Net P&L:", formatPnL(it, uiState.fiat), pnlColor(it)) +} +``` + +- [ ] **Krok 4: Build check + manualni test** + +- [ ] **Krok 5: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/ +git commit -m "feat(sell): add realized + net P&L to PortfolioScreen" +``` + +--- + +### Task 30: DashboardScreen - open sells card + +**Soubory:** +- Upravit: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt` +- Upravit: Dashboard ViewModel + +- [ ] **Krok 1: Expose open sells per plan ve ViewModelu** + +```kotlin +val openSellsByPlan: StateFlow>> = + transactionDao.observeOpenSells() + .map { list -> list.groupBy { it.planId } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyMap()) +``` + +(`observeOpenSells()` - pridat do DAO pokud neexistuje: `SELECT * FROM transactions WHERE side='SELL' AND status IN ('PENDING','PARTIAL')`) + +- [ ] **Krok 2: Render cards v DashboardScreen** + +Pro kazdy plan s openSells: + +```kotlin +uiState.openSellsByPlan.forEach { (planId, sells) -> + val plan = planLookup[planId] ?: return@forEach + Card(onClick = { nav.navigate("plan/$planId") }) { + Column(Modifier.padding(16.dp)) { + Text("${plan.name}: ${sells.size} open sell${if (sells.size > 1) "s" else ""}") + sells.firstOrNull()?.let { tx -> + Text("${tx.requestedCryptoAmount ?: tx.cryptoAmount} ${tx.crypto} @ ${tx.limitPrice} ${tx.fiat}") + } + uiState.spotPrices[plan.crypto]?.let { + Text("Aktualni trzni: $it ${plan.fiat}", + style = MaterialTheme.typography.bodySmall) + } + } + } +} +``` + +- [ ] **Krok 3: Build check + manualni test** + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt +git commit -m "feat(sell): add open sells card to DashboardScreen" +``` + +--- + +## Faze 9: Edge cases, polish, testing + +### Task 31: Block plan delete pokud jsou open ordery + +**Soubory:** +- Upravit: `DeleteDcaPlanUseCase` (grep pro tento nebo obdobny) +- Upravit: UI kde se spousti delete (pravdepodobne PlanDetailsScreen nebo EditPlanScreen) + +- [ ] **Krok 1: Najit DeletePlanUseCase** + +```bash +grep -rn "DeleteDcaPlan\|deletePlan" accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ +``` + +- [ ] **Krok 2: Pridat check v use case** + +```kotlin +suspend operator fun invoke(planId: Long): Result { + val openSells = database.transactionDao().observeOpenSellsForPlan(planId).first() + if (openSells.isNotEmpty()) { + return Result.failure( + IllegalStateException("Plan ma ${openSells.size} open sell orderu. Zrus je nejdrive.") + ) + } + database.dcaPlanDao().deletePlan(planId) + return Result.success(Unit) +} +``` + +- [ ] **Krok 3: V UI zobrazit alert pri failure** + +V delete handler (pravdepodobne ve ViewModelu): + +```kotlin +fun deletePlan(planId: Long) = viewModelScope.launch { + val result = deletePlanUseCase(planId) + if (result.isFailure) { + _dialog.emit(Dialog.CannotDelete(result.exceptionOrNull()?.message)) + } +} +``` + +A Compose: + +```kotlin +uiState.dialog?.let { d -> + when (d) { + is Dialog.CannotDelete -> AlertDialog( + onDismissRequest = { vm.dismissDialog() }, + title = { Text("Nelze smazat plan") }, + text = { Text(d.message ?: "Plan ma otevrene ordery.") }, + confirmButton = { Button(vm::dismissDialog) { Text("OK") } } + ) + } +} +``` + +- [ ] **Krok 4: Build check + manualni test** + +Smaz plan s open sell order -> alert. Cancel order. Smaz znovu -> uspech. + +- [ ] **Krok 5: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/ +git commit -m "feat(sell): block plan delete when open sell orders exist" +``` + +--- + +### Task 32: Pull-to-refresh integration + +**Soubory:** +- Upravit: `PlanDetailsScreen.kt` + +- [ ] **Krok 1: Wire pull-to-refresh** + +Pokud existuje `PullToRefreshBox` nebo `SwipeRefresh` v projektu, reuse. Jinak: + +```kotlin +val refreshing by viewModel.refreshing.collectAsStateWithLifecycle() +val state = rememberPullToRefreshState() + +Box( + Modifier.pullToRefresh(state, refreshing, { viewModel.refresh() }) +) { + // existujici content + PullToRefreshContainer(state = state, modifier = Modifier.align(Alignment.TopCenter)) +} +``` + +- [ ] **Krok 2: V ViewModelu** + +```kotlin +private val _refreshing = MutableStateFlow(false) +val refreshing = _refreshing.asStateFlow() + +fun refresh() = viewModelScope.launch { + _refreshing.value = true + try { + resolvePendingTransactionsUseCase() + } finally { + _refreshing.value = false + } +} +``` + +- [ ] **Krok 3: Build check + manualni test** + +Pull-down -> spinner -> pokud byly pending/partial ordery, aktualizovat. + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/ +git commit -m "feat(sell): add pull-to-refresh to plan-detail for order status polling" +``` + +--- + +### Task 33: Manualni sandbox E2E test - Coinmate + +**Zadne soubory ke zmene - ciste testovaci task.** + +- [ ] **Krok 1: Pripravit sandbox** + +Zapnout sandbox mode v Settings. Overit ze Coinmate credentials sandbox jsou platne. + +- [ ] **Krok 2: Setup** + +- Vytvorit plan BTC/CZK s `allowSells=true`, `targetProfitAmount=10000` +- Spustit 3 rucni buys po 100 CZK aby byl v planu nejaky BTC (pres "Run Now") +- Overit ze buy transakce jsou status=COMPLETED, held > 0 + +- [ ] **Krok 3: Scenare** + +**Scenar A - standardni limit sell nad trhem:** +- Otevrit wizard, 25% z held, price = spot × 1.1 +- Pokracovat -> Potvrdit -> Odeslat +- Overit: + - V plan-detail open orders: 1 order, status=PENDING + - Pred pull-to-refresh: status se nemeni + - Po chvili / pull-to-refresh: status stale PENDING (order se nefilluje nad trhem) + +**Scenar B - instant fill (limit pod trhem):** +- Wizard, 10% z held, price = spot × 0.5 +- Validace: instant-fill banner zobrazen +- Odeslat +- Po pull-to-refresh (1-2s): status = COMPLETED, cryptoAmount = requested + +**Scenar C - cancel:** +- Wizard, price = spot × 2 (nad trhem) +- Odeslat -> PENDING +- V plan-detail kliknout cancel ikonku +- Overit: + - Tlacitko cancel trigger + - Transakce: status = FAILED + - Na burze (pres web): order canceled + +**Scenar D - plan delete block:** +- Plan s open sell orderem +- Smazat plan -> alert "Nelze smazat" +- Cancel order +- Smazat plan -> uspech + +- [ ] **Krok 4: Verifikace migrace** + +Backup aktualni DB (export pres existujici backup flow). Pokud byly v backupu plany, zkusit restore na cistou instalaci -> plany + transakce projdou mapovanim, vcetne `allowSells`, `side`, atd. + +- [ ] **Krok 5: Poznamky k chybam pripadne opravy** + +Pokud scenare selzou, opravit konkretni bug (identifikovat task) a projit scenare znovu. + +- [ ] **Krok 6: Commit (pokud byly opravy)** + +```bash +git commit -m "fix(sell): ..." +``` + +--- + +### Task 34: Manualni sandbox E2E test - Binance + +**Zadne soubory ke zmene - ciste testovaci task.** + +- [ ] **Krok 1: Setup Binance testnet credentials** + +Dle dokumentace aplikace. Zapnout sandbox mode. + +- [ ] **Krok 2: Opakovat scenare A-D z Task 33, tentokrat na Binance** + +Plan BTC/USDT nebo BTC/EUR. Krome orderu pres Binance endpointu `/api/v3/order`, overit: +- `limitSell` zakonci s numerickym orderId (Binance vraci long) +- `cancelOrder` vyzaduje `symbol + orderId` - funguje +- `getOrderStatus` vraci `executedQty` a `cummulativeQuoteQty` ktere se mapuji spravne + +- [ ] **Krok 3: Verifikace - vydal jsi appku s oboustrannou podporou** + +Po obou E2E testech (Coinmate + Binance) je MVP ready. + +- [ ] **Krok 4: Final commit (pokud opravy)** + +--- + +## Summary + +**Celkem tasku:** 34 +**Predpokladany rozsah:** 3-5 dnu pro experienced Kotlin/Compose dev, vice pro nezkusene s Room / WorkManager / Hilt patterns. + +**Kriticke zavislosti v poradi:** +- Tasky 1-7 (datovy model) MUSI byt hotove pred 8+ (Exchange API) +- Task 8 je breaking change, opravy v 9-11 +- Task 12 zavisi na 6 (DAO queries) a 8-11 (API refactor) +- Tasky 20+ (UI) zavisi na 12-16 (use cases) +- Tasky 33-34 (E2E) zavisi na vsem + +**Vedlejsi dulezite:** +- ProGuard keep rules: Task 7 Krok 6 - overit ze Gson modely v `domain.model` package nejsou shrinknute +- DAO `observeOpenSells` (bez planId filter) pro Dashboard - Task 30 +- Reuse `ScheduleBuilderState` Compose komponenty - Task 20 Krok 5 (pripadne extract do shared) From 61aa6b7db9ea37f30dc2146ac417def2138a8974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 21:58:21 +0200 Subject: [PATCH 04/75] feat(sell): add TransactionSide enum + new fields to TransactionEntity and DcaPlanEntity - TransactionSide enum (BUY, SELL) + TypeConverter - TransactionEntity: side, limitPrice, requestedCryptoAmount (all nullable/defaulted) - DcaPlanEntity: allowSells (default false), targetProfitAmount (nullable) Co-Authored-By: Claude Opus 4.7 --- .../com/accbot/dca/data/local/Entities.kt | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt index d98b377..a261cd9 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt @@ -19,6 +19,11 @@ import java.time.Instant */ enum class NotificationType { PURCHASE, ERROR, LOW_BALANCE, WITHDRAWAL_THRESHOLD, NETWORK_RETRY, MISSED_PURCHASES } +/** + * Direction of a transaction - BUY for DCA purchases, SELL for user-initiated limit sell orders. + */ +enum class TransactionSide { BUY, SELL } + /** * Room type converters */ @@ -99,6 +104,17 @@ class Converters { Log.w(TAG, "Unknown NotificationType '$value', falling back to ERROR") NotificationType.ERROR } + + @TypeConverter + fun fromTransactionSide(value: TransactionSide): String = value.name + + @TypeConverter + fun toTransactionSide(value: String): TransactionSide = try { + TransactionSide.valueOf(value) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Unknown TransactionSide '$value', falling back to BUY") + TransactionSide.BUY + } } /** @@ -181,7 +197,17 @@ data class DcaPlanEntity( val originalScheduledAt: Instant? = null, val missedPurchaseCount: Int = 0, /** Order for Dashboard display. Lower values shown first. */ - val displayOrder: Int = 0 + val displayOrder: Int = 0, + /** + * Opt-in per-plan toggle for sell extension. When true (and global trading is enabled), + * plan-detail shows P&L card, open sell orders list, and sell wizard button. + */ + val allowSells: Boolean = false, + /** + * Optional profit goal (in [fiat]). When set, plan-detail shows progress bar toward this. + * Null when user didn't specify a target. + */ + val targetProfitAmount: BigDecimal? = null ) /** @@ -225,7 +251,20 @@ data class TransactionEntity( val exchangeOrderId: String? = null, val errorMessage: String? = null, val warningMessage: String? = null, - val executedAt: Instant = Instant.now() + val executedAt: Instant = Instant.now(), + /** + * BUY for DCA purchases (default), SELL for limit sell orders placed via sell extension. + */ + val side: TransactionSide = TransactionSide.BUY, + /** + * Requested limit price for SELL orders; null for market BUYs. + */ + val limitPrice: BigDecimal? = null, + /** + * Original requested crypto amount for SELL orders (fixed across lifecycle). + * [cryptoAmount] tracks filled amount (progresses 0 -> requested). Null for BUYs. + */ + val requestedCryptoAmount: BigDecimal? = null ) /** From dc5afd43bda0d5abaa1ffb63281f729c9759ed1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 22:06:11 +0200 Subject: [PATCH 05/75] feat(sell): Faze 1 - data model rozsireni pro sell extension - TransactionSide enum (BUY/SELL) v domain layer + Converter - TransactionEntity + Transaction: side, limitPrice, requestedCryptoAmount - DcaPlanEntity + DcaPlan: allowSells, targetProfitAmount - Room migration 20->21 pro nova pole + idx_tx_plan_side_status - EntityMappers: toEntity/toDomain pro DcaPlan + Transaction - DAO: getResolvablePendingTransactions (PENDING+PARTIAL), countOpenSells, observeOpenSellsForPlan, observeAllOpenSells, updateResolvedTransaction (guarded update pro race s cancelem) - Backup models v3: allowSells, targetProfitAmount, side, limitPrice, requestedCryptoAmount (backward-kompatibilni) - Backup collector + restorer rozsireni pro nova pole Co-Authored-By: Claude Opus 4.7 --- .../dca/data/local/BackupDataCollector.kt | 9 +++- .../dca/data/local/BackupDataRestorer.kt | 9 +++- .../java/com/accbot/dca/data/local/Daos.kt | 47 ++++++++++++++++ .../com/accbot/dca/data/local/DcaDatabase.kt | 17 +++++- .../com/accbot/dca/data/local/Entities.kt | 6 +-- .../accbot/dca/data/local/EntityMappers.kt | 54 ++++++++++++++++++- .../accbot/dca/domain/model/BackupModels.kt | 14 ++++- .../com/accbot/dca/domain/model/Models.kt | 30 ++++++++++- 8 files changed, 169 insertions(+), 17 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataCollector.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataCollector.kt index 76fedfc..fb90c8f 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataCollector.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataCollector.kt @@ -130,7 +130,9 @@ class BackupDataCollector @Inject constructor( lastExecutedAt = lastExecutedAt?.toEpochMilli(), nextExecutionAt = nextExecutionAt?.toEpochMilli(), targetAmount = targetAmount?.toPlainString(), - connectionId = connectionId + connectionId = connectionId, + allowSells = allowSells, + targetProfitAmount = targetProfitAmount?.toPlainString() ) private fun TransactionEntity.toBackup() = BackupTransaction( @@ -149,7 +151,10 @@ class BackupDataCollector @Inject constructor( errorMessage = errorMessage, warningMessage = warningMessage, executedAt = executedAt.toEpochMilli(), - connectionId = connectionId + connectionId = connectionId, + side = side.name, + limitPrice = limitPrice?.toPlainString(), + requestedCryptoAmount = requestedCryptoAmount?.toPlainString() ) private fun NotificationEntity.toBackup() = BackupNotification( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt index 5489b46..bef27bb 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt @@ -343,7 +343,9 @@ class BackupDataRestorer @Inject constructor( createdAt = Instant.ofEpochMilli(createdAt), lastExecutedAt = lastExecutedAt?.let { Instant.ofEpochMilli(it) }, nextExecutionAt = effectiveNext, - targetAmount = targetAmount?.let { BigDecimal(it) } + targetAmount = targetAmount?.let { BigDecimal(it) }, + allowSells = allowSells, + targetProfitAmount = targetProfitAmount?.let { BigDecimal(it) } ) } @@ -363,7 +365,10 @@ class BackupDataRestorer @Inject constructor( exchangeOrderId = exchangeOrderId, errorMessage = errorMessage, warningMessage = warningMessage, - executedAt = Instant.ofEpochMilli(executedAt) + executedAt = Instant.ofEpochMilli(executedAt), + side = try { com.accbot.dca.domain.model.TransactionSide.valueOf(side) } catch (e: Exception) { com.accbot.dca.domain.model.TransactionSide.BUY }, + limitPrice = limitPrice?.let { BigDecimal(it) }, + requestedCryptoAmount = requestedCryptoAmount?.let { BigDecimal(it) } ) private fun BackupWithdrawal.toEntity(remappedPlanId: Long, connectionId: Long?) = WithdrawalEntity( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt index 076a406..b843eb4 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt @@ -2,6 +2,7 @@ package com.accbot.dca.data.local import androidx.room.* import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.TransactionStatus import kotlinx.coroutines.flow.Flow import java.math.BigDecimal import java.time.Instant @@ -275,6 +276,52 @@ interface TransactionDao { @Query("SELECT * FROM transactions WHERE status = 'PENDING' AND exchangeOrderId IS NOT NULL") suspend fun getPendingTransactionsWithOrderId(): List + @Query("SELECT * FROM transactions WHERE status IN ('PENDING', 'PARTIAL') AND exchangeOrderId IS NOT NULL") + suspend fun getResolvablePendingTransactions(): List + + @Query("SELECT COUNT(*) FROM transactions WHERE side = 'SELL' AND status IN ('PENDING', 'PARTIAL')") + suspend fun countOpenSells(): Int + + @Query(""" + SELECT * FROM transactions + WHERE planId = :planId + AND side = 'SELL' + AND status IN ('PENDING', 'PARTIAL') + ORDER BY executedAt DESC + """) + fun observeOpenSellsForPlan(planId: Long): Flow> + + @Query(""" + SELECT * FROM transactions + WHERE side = 'SELL' AND status IN ('PENDING', 'PARTIAL') + ORDER BY executedAt DESC + """) + fun observeAllOpenSells(): Flow> + + /** + * Guarded update for resolving an order. Only updates rows still in PENDING/PARTIAL + * state - prevents races with concurrent user cancel (which sets status=FAILED). + * Returns number of rows updated (0 = race lost, order state already changed). + */ + @Query(""" + UPDATE transactions + SET status = :newStatus, + cryptoAmount = :cryptoAmount, + fiatAmount = :fiatAmount, + price = :price, + fee = :fee + WHERE id = :id + AND status IN ('PENDING', 'PARTIAL') + """) + suspend fun updateResolvedTransaction( + id: Long, + newStatus: TransactionStatus, + cryptoAmount: BigDecimal, + fiatAmount: BigDecimal, + price: BigDecimal, + fee: BigDecimal + ): Int + @Query("SELECT exchangeOrderId FROM transactions WHERE planId = :planId AND exchangeOrderId IS NOT NULL") suspend fun getExchangeOrderIdsByPlan(planId: Long): List diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt index 4227cfe..80d2701 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt @@ -20,7 +20,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase WithdrawalThresholdEntity::class, ExchangeConnectionEntity::class ], - version = 20, + version = 21, exportSchema = true ) @TypeConverters(Converters::class) @@ -374,6 +374,19 @@ abstract class DcaDatabase : RoomDatabase() { } } + // Migration from version 20 to 21: Add sell extension fields to dca_plans and transactions. + // Enables opt-in limit sell orders, P&L tracking, and optional profit targets. + private val MIGRATION_20_21 = object : Migration(20, 21) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE dca_plans ADD COLUMN allowSells INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE dca_plans ADD COLUMN targetProfitAmount TEXT DEFAULT NULL") + database.execSQL("ALTER TABLE transactions ADD COLUMN side TEXT NOT NULL DEFAULT 'BUY'") + database.execSQL("ALTER TABLE transactions ADD COLUMN limitPrice TEXT DEFAULT NULL") + database.execSQL("ALTER TABLE transactions ADD COLUMN requestedCryptoAmount TEXT DEFAULT NULL") + database.execSQL("CREATE INDEX IF NOT EXISTS idx_tx_plan_side_status ON transactions(planId, side, status)") + } + } + // Migration from version 9 to 10: Add notifications and withdrawal_thresholds tables private val MIGRATION_9_10 = object : Migration(9, 10) { override fun migrate(database: SupportSQLiteDatabase) { @@ -476,7 +489,7 @@ abstract class DcaDatabase : RoomDatabase() { DcaDatabase::class.java, databaseName ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21) // Only allow destructive migration on app downgrade, never on failed upgrade // This protects user's transaction history from accidental deletion .fallbackToDestructiveMigrationOnDowngrade() diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt index a261cd9..3b30016 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt @@ -9,6 +9,7 @@ import androidx.room.TypeConverters import com.accbot.dca.domain.model.DcaFrequency import com.accbot.dca.domain.model.DcaStrategy import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.TransactionSide import com.accbot.dca.domain.model.TransactionStatus import com.accbot.dca.domain.model.WithdrawalStatus import java.math.BigDecimal @@ -19,11 +20,6 @@ import java.time.Instant */ enum class NotificationType { PURCHASE, ERROR, LOW_BALANCE, WITHDRAWAL_THRESHOLD, NETWORK_RETRY, MISSED_PURCHASES } -/** - * Direction of a transaction - BUY for DCA purchases, SELL for user-initiated limit sell orders. - */ -enum class TransactionSide { BUY, SELL } - /** * Room type converters */ diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt index f051652..4514dd9 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt @@ -23,7 +23,32 @@ fun DcaPlanEntity.toDomain() = DcaPlan( lastExecutedAt = lastExecutedAt, nextExecutionAt = nextExecutionAt, targetAmount = targetAmount, - displayOrder = displayOrder + displayOrder = displayOrder, + allowSells = allowSells, + targetProfitAmount = targetProfitAmount +) + +fun DcaPlan.toEntity() = DcaPlanEntity( + id = id, + exchange = exchange, + connectionId = connectionId, + name = name, + crypto = crypto, + fiat = fiat, + amount = amount, + frequency = frequency, + cronExpression = cronExpression, + strategy = strategy, + isEnabled = isEnabled, + withdrawalEnabled = withdrawalEnabled, + withdrawalAddress = withdrawalAddress, + createdAt = createdAt, + lastExecutedAt = lastExecutedAt, + nextExecutionAt = nextExecutionAt, + targetAmount = targetAmount, + displayOrder = displayOrder, + allowSells = allowSells, + targetProfitAmount = targetProfitAmount ) fun TransactionEntity.toDomain() = Transaction( @@ -42,7 +67,32 @@ fun TransactionEntity.toDomain() = Transaction( exchangeOrderId = exchangeOrderId, errorMessage = errorMessage, warningMessage = warningMessage, - executedAt = executedAt + executedAt = executedAt, + side = side, + limitPrice = limitPrice, + requestedCryptoAmount = requestedCryptoAmount +) + +fun Transaction.toEntity() = TransactionEntity( + id = id, + planId = planId, + exchange = exchange, + connectionId = connectionId, + crypto = crypto, + fiat = fiat, + fiatAmount = fiatAmount, + cryptoAmount = cryptoAmount, + price = price, + fee = fee, + feeAsset = feeAsset, + status = status, + exchangeOrderId = exchangeOrderId, + errorMessage = errorMessage, + warningMessage = warningMessage, + executedAt = executedAt, + side = side, + limitPrice = limitPrice, + requestedCryptoAmount = requestedCryptoAmount ) fun NotificationEntity.toDomain() = AppNotification( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt index 0469b2d..f3f7eb3 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt @@ -88,7 +88,11 @@ data class BackupPlan( @SerializedName("nextExecutionAt") val nextExecutionAt: Long? = null, @SerializedName("targetAmount") val targetAmount: String? = null, // BigDecimal.toPlainString() /** v2+: source connection id (backup-local). Null for legacy v1 backups. */ - @SerializedName("connectionId") val connectionId: Long? = null + @SerializedName("connectionId") val connectionId: Long? = null, + /** v3+: sell extension - opt-in per-plan. */ + @SerializedName("allowSells") val allowSells: Boolean = false, + /** v3+: sell extension - optional profit target (BigDecimal.toPlainString). */ + @SerializedName("targetProfitAmount") val targetProfitAmount: String? = null ) /** @@ -142,7 +146,13 @@ data class BackupTransaction( @SerializedName("warningMessage") val warningMessage: String? = null, @SerializedName("executedAt") val executedAt: Long = 0, /** v2+: source connection id (backup-local). Null for legacy v1 backups. */ - @SerializedName("connectionId") val connectionId: Long? = null + @SerializedName("connectionId") val connectionId: Long? = null, + /** v3+: sell extension - BUY or SELL. Default BUY for legacy transactions. */ + @SerializedName("side") val side: String = "BUY", + /** v3+: sell extension - limit price for SELL orders. */ + @SerializedName("limitPrice") val limitPrice: String? = null, + /** v3+: sell extension - original requested crypto amount for SELL orders. */ + @SerializedName("requestedCryptoAmount") val requestedCryptoAmount: String? = null ) /** diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt index 8f0485d..3299059 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt @@ -171,7 +171,15 @@ data class DcaPlan( val nextExecutionAt: Instant? = null, val targetAmount: BigDecimal? = null, /** Order for Dashboard display. Lower values shown first. */ - val displayOrder: Int = 0 + val displayOrder: Int = 0, + /** + * Opt-in per-plan toggle for sell extension. + */ + val allowSells: Boolean = false, + /** + * Optional profit goal in [fiat]. Null when user didn't specify a target. + */ + val targetProfitAmount: BigDecimal? = null ) /** @@ -209,7 +217,20 @@ data class Transaction( val exchangeOrderId: String? = null, val errorMessage: String? = null, val warningMessage: String? = null, - val executedAt: Instant = Instant.now() + val executedAt: Instant = Instant.now(), + /** + * BUY for DCA purchases (default), SELL for limit sell orders. + */ + val side: TransactionSide = TransactionSide.BUY, + /** + * Requested limit price for SELL orders; null for market BUYs. + */ + val limitPrice: BigDecimal? = null, + /** + * Original requested crypto amount for SELL orders (fixed across lifecycle). + * [cryptoAmount] tracks filled amount progressively. Null for BUYs. + */ + val requestedCryptoAmount: BigDecimal? = null ) enum class TransactionStatus { @@ -219,6 +240,11 @@ enum class TransactionStatus { PARTIAL } +/** + * Direction of a transaction - BUY for DCA purchases, SELL for user-initiated limit sell orders. + */ +enum class TransactionSide { BUY, SELL } + /** * Withdrawal record */ From db4cdf62b1bb11333b761b5f702feade13a8ceeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 22:13:42 +0200 Subject: [PATCH 06/75] feat(sell): refactor ExchangeApi - OrderStatusResult + limitSell/cancelOrder hooks Task 8 + 9 z Faze 2: - novy data class OrderStatusResult zastupujici Transaction? ve vraceni getOrderStatus - getOrderStatus dostava nove (orderId, crypto, fiat) - Binance potrebuje symbol - pridany interface defaulty: limitSell, cancelOrder, supportsLimitSell - CoinbaseApi.getOrderStatus a KrakenApi.getOrderStatus prepsany na novy tvar ResolvePendingTransactionsUseCase zatim nebuildi - oprava je soucasti Faze 3. --- .../com/accbot/dca/exchange/CoinbaseApi.kt | 55 ++++++++++-------- .../com/accbot/dca/exchange/ExchangeApi.kt | 57 +++++++++++++++++- .../accbot/dca/exchange/OrderStatusResult.kt | 27 +++++++++ .../com/accbot/dca/exchange/OtherExchanges.kt | 58 ++++++++++--------- 4 files changed, 142 insertions(+), 55 deletions(-) create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/exchange/OrderStatusResult.kt diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt index f2d4a68..3057064 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt @@ -230,7 +230,11 @@ class CoinbaseApi( val fee: BigDecimal ) - override suspend fun getOrderStatus(orderId: String): Transaction? = withContext(Dispatchers.IO) { + override suspend fun getOrderStatus( + orderId: String, + crypto: String, + fiat: String + ): OrderStatusResult? = withContext(Dispatchers.IO) { try { val request = buildGetRequest("/api/v3/brokerage/orders/historical/$orderId") client.newCall(request).execute().use { response -> @@ -241,30 +245,33 @@ class CoinbaseApi( val order = json.optJSONObject("order") ?: return@withContext null val status = order.optString("status", "") - if (status == "FILLED") { - val filledSize = BigDecimal(order.optString("filled_size", "0")) - val avgPrice = BigDecimal(order.optString("average_filled_price", "0")) - val totalFees = BigDecimal(order.optString("total_fees", "0")) - val cost = if (filledSize > BigDecimal.ZERO && avgPrice > BigDecimal.ZERO) { - filledSize.multiply(avgPrice).setScale(8, RoundingMode.HALF_UP) - } else BigDecimal.ZERO - - Transaction( - planId = 0, - exchange = Exchange.COINBASE, - crypto = "", - fiat = "", - fiatAmount = cost, - cryptoAmount = filledSize, - price = avgPrice, - fee = totalFees, - status = TransactionStatus.COMPLETED, - exchangeOrderId = orderId, - executedAt = Instant.now() - ) - } else { - null + val filledSize = BigDecimal(order.optString("filled_size", "0")) + val avgPrice = BigDecimal(order.optString("average_filled_price", "0")) + val totalFees = BigDecimal(order.optString("total_fees", "0")) + val filledFiat = if (filledSize > BigDecimal.ZERO && avgPrice > BigDecimal.ZERO) { + filledSize.multiply(avgPrice).setScale(8, RoundingMode.HALF_UP) + } else BigDecimal.ZERO + + val mappedStatus = when (status) { + "FILLED" -> TransactionStatus.COMPLETED + "OPEN", "PENDING" -> if (filledSize > BigDecimal.ZERO) { + TransactionStatus.PARTIAL + } else TransactionStatus.PENDING + "PARTIALLY_FILLED" -> TransactionStatus.PARTIAL + "CANCELLED", "CANCEL_QUEUED", "EXPIRED", "FAILED", "REJECTED" -> + if (filledSize > BigDecimal.ZERO) TransactionStatus.PARTIAL + else TransactionStatus.FAILED + else -> return@withContext null } + + OrderStatusResult( + status = mappedStatus, + filledCryptoAmount = filledSize, + filledFiatAmount = filledFiat, + avgFillPrice = if (filledSize > BigDecimal.ZERO) avgPrice else null, + fee = totalFees, + feeAsset = fiat + ) } } catch (_: Exception) { null diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/ExchangeApi.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/ExchangeApi.kt index ee66a83..fedc68f 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/ExchangeApi.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/ExchangeApi.kt @@ -63,11 +63,62 @@ interface ExchangeApi { /** * Query the status and fill details of a previously placed order. - * Used to resolve PENDING transactions whose fill details weren't available at order time. + * Used to resolve PENDING/PARTIAL transactions whose fill details weren't available at + * order time, and to track progress of open limit sell orders. + * + * Binance requires the trading pair as `symbol=${crypto}${fiat}` context; other + * exchanges (Coinmate, Coinbase, Kraken) ignore the crypto/fiat params. + * * @param orderId The exchange order ID - * @return Filled transaction details, or null if still pending/unknown + * @param crypto Cryptocurrency symbol (e.g., "BTC") - used by Binance + * @param fiat Fiat currency (e.g., "EUR") - used by Binance + * @return Current order status + fill details, or null if unknown / parse failure. */ - suspend fun getOrderStatus(orderId: String): Transaction? = null + suspend fun getOrderStatus(orderId: String, crypto: String, fiat: String): OrderStatusResult? = null + + /** + * Place a limit sell order. + * + * On success returns a [DcaResult.Success] with a PENDING [Transaction] that has: + * - side = SELL + * - status = PENDING + * - cryptoAmount = ZERO (filled amount, updated later by resolver) + * - fiatAmount = ZERO (filled fiat, updated later by resolver) + * - limitPrice = [limitPrice] + * - requestedCryptoAmount = [cryptoAmount] + * - price = [limitPrice] (initial value; updated to avg fill price when resolved) + * - exchangeOrderId = order id returned by exchange + * + * The caller is responsible for setting planId and connectionId. + * + * Default: unsupported. + */ + suspend fun limitSell( + crypto: String, + fiat: String, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal + ): DcaResult = throw UnsupportedOperationException( + "AccBot zatim nepodporuje limit sell pro ${exchange.displayName}" + ) + + /** + * Cancel an open order. + * + * Binance requires the trading pair as context; other exchanges ignore crypto/fiat. + * + * Default: unsupported. + */ + suspend fun cancelOrder(orderId: String, crypto: String, fiat: String): Result = + Result.failure(UnsupportedOperationException( + "AccBot zatim nepodporuje cancel order pro ${exchange.displayName}" + )) + + /** + * Whether this exchange implementation supports placing limit sell orders. + * Used by the UI to hide the "Prodat" action for unsupported exchanges. + */ + val supportsLimitSell: Boolean get() = false /** * Get trade history for a currency pair. diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/OrderStatusResult.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/OrderStatusResult.kt new file mode 100644 index 0000000..71b6eec --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/OrderStatusResult.kt @@ -0,0 +1,27 @@ +package com.accbot.dca.exchange + +import com.accbot.dca.domain.model.TransactionStatus +import java.math.BigDecimal + +/** + * Result of an order status query. + * + * Used by the ResolvePendingTransactionsUseCase to update PENDING/PARTIAL transactions + * with fill information from the exchange. + * + * @property status Current order status mapped to our TransactionStatus enum. + * @property filledCryptoAmount Crypto amount filled so far (0 if not yet filled). + * @property filledFiatAmount Fiat amount of fills so far (0 if not yet filled). + * @property avgFillPrice Volume-weighted average fill price, or null if not yet filled. + * @property fee Total fee accrued by the order so far, or null if the exchange doesn't + * report fees on the order object (e.g. Binance - fees are per-trade). + * @property feeAsset Asset in which the fee is denominated, or null if [fee] is null. + */ +data class OrderStatusResult( + val status: TransactionStatus, + val filledCryptoAmount: BigDecimal, + val filledFiatAmount: BigDecimal, + val avgFillPrice: BigDecimal?, + val fee: BigDecimal?, + val feeAsset: String? +) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt index d3b4649..4aed1b6 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt @@ -211,7 +211,11 @@ class KrakenApi( val price: BigDecimal ) - override suspend fun getOrderStatus(orderId: String): Transaction? = withContext(Dispatchers.IO) { + override suspend fun getOrderStatus( + orderId: String, + crypto: String, + fiat: String + ): OrderStatusResult? = withContext(Dispatchers.IO) { try { val (_, _, body) = executePrivateRequest("/0/private/QueryOrders", "txid=$orderId&trades=true") val json = JSONObject(body) @@ -222,34 +226,32 @@ class KrakenApi( val order = result.optJSONObject(orderId) ?: return@withContext null val status = order.optString("status") - if (status == "closed") { - val volExec = BigDecimal(order.optString("vol_exec", "0")) - val cost = BigDecimal(order.optString("cost", "0")) - val fee = BigDecimal(order.optString("fee", "0")) - val price = if (volExec > BigDecimal.ZERO) { - cost.divide(volExec, 8, RoundingMode.HALF_UP) - } else BigDecimal.ZERO - - // Parse pair info from order description - val descr = order.optJSONObject("descr") - val pair = descr?.optString("pair", "") ?: "" - - Transaction( - planId = 0, - exchange = Exchange.KRAKEN, - crypto = "", - fiat = "", - fiatAmount = cost, - cryptoAmount = volExec, - price = price, - fee = fee, - status = TransactionStatus.COMPLETED, - exchangeOrderId = orderId, - executedAt = Instant.now() - ) - } else { - null + val volExec = BigDecimal(order.optString("vol_exec", "0")) + val cost = BigDecimal(order.optString("cost", "0")) + val fee = BigDecimal(order.optString("fee", "0")) + val price = if (volExec > BigDecimal.ZERO) { + cost.divide(volExec, 8, RoundingMode.HALF_UP) + } else BigDecimal.ZERO + + val mappedStatus = when (status) { + "closed" -> TransactionStatus.COMPLETED + "open", "pending" -> if (volExec > BigDecimal.ZERO) { + TransactionStatus.PARTIAL + } else TransactionStatus.PENDING + "canceled", "cancelled", "expired" -> + if (volExec > BigDecimal.ZERO) TransactionStatus.PARTIAL + else TransactionStatus.FAILED + else -> return@withContext null } + + OrderStatusResult( + status = mappedStatus, + filledCryptoAmount = volExec, + filledFiatAmount = cost, + avgFillPrice = if (volExec > BigDecimal.ZERO) price else null, + fee = fee, + feeAsset = fiat + ) } catch (_: Exception) { null } From dcf04783c0a70eeab77f4d0bf0b8afc5264c65a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 22:15:42 +0200 Subject: [PATCH 07/75] feat(sell): implement CoinmateApi limitSell + cancelOrder + getOrderStatus Task 10: - supportsLimitSell = true - limitSell: POST /sellLimit s amount/price/currencyPair, sdili stejny signing pattern jako marketBuy (clientId + publicKey + nonce + signature) - cancelOrder: POST /cancelOrder s orderId - getOrderStatus: POST /orderById, mapuje OPEN/FILLED/PARTIALLY_FILLED/CANCELLED/EXPIRED -> TransactionStatus, filledAmount = originalAmount - remainingAmount - Coinmate nevraci aggregate fee na orderById, takze fee=null v OrderStatusResult (resolver muze fetchnout z tradeHistory pokud bude potreba) --- .../com/accbot/dca/exchange/CoinmateApi.kt | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt index 0cbf639..98ad4d9 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt @@ -26,6 +26,8 @@ class CoinmateApi( override val exchange = Exchange.COINMATE + override val supportsLimitSell: Boolean = true + // Coinmate taker fee: 0.35% (same as .NET CoinmateAPI.getTakerFee()) private val takerFeeRate = BigDecimal("0.0035") @@ -202,6 +204,193 @@ class CoinmateApi( return null } + override suspend fun limitSell( + crypto: String, + fiat: String, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal + ): DcaResult = withContext(Dispatchers.IO) { + try { + val pair = "${crypto}_${fiat}" + val nonce = System.currentTimeMillis() + val signature = createSignature(nonce) + + val formBody = FormBody.Builder() + .add("clientId", clientId) + .add("publicKey", credentials.apiKey) + .add("nonce", nonce.toString()) + .add("signature", signature) + .add("currencyPair", pair) + // Coinmate expects the crypto amount (not fiat total) for sellLimit + .add("amount", cryptoAmount.setScale(8, RoundingMode.DOWN).toPlainString()) + .add("price", limitPrice.setScale(2, RoundingMode.HALF_UP).toPlainString()) + .build() + + val request = Request.Builder() + .url("$baseUrl/sellLimit") + .post(formBody) + .build() + + client.newCall(request).execute().use { response -> + val body = response.body?.string() ?: throw Exception("Empty response") + + if (!response.isSuccessful) { + val isRetryable = response.code in 500..599 || response.code == 429 + return@withContext DcaResult.Error("HTTP ${response.code}", retryable = isRetryable) + } + + val json = JSONObject(body) + + if (json.optBoolean("error", true)) { + val errorMessage = json.optString("errorMessage", "Unknown error") + return@withContext DcaResult.Error(errorMessage, retryable = false) + } + + // sellLimit returns the order ID in "data" + val orderId = json.get("data").toString() + + DcaResult.Success( + Transaction( + planId = 0, // caller fills in + connectionId = null, // caller fills in + exchange = Exchange.COINMATE, + crypto = crypto, + fiat = fiat, + fiatAmount = BigDecimal.ZERO, + cryptoAmount = BigDecimal.ZERO, + price = limitPrice, + fee = BigDecimal.ZERO, + feeAsset = "", + status = TransactionStatus.PENDING, + exchangeOrderId = orderId, + executedAt = Instant.now(), + side = TransactionSide.SELL, + limitPrice = limitPrice, + requestedCryptoAmount = cryptoAmount + ) + ) + } + } catch (e: java.io.IOException) { + DcaResult.Error(e.message ?: "Network error", retryable = true) + } catch (e: Exception) { + DcaResult.Error(e.message ?: "Unknown error", retryable = false) + } + } + + override suspend fun cancelOrder( + orderId: String, + crypto: String, + fiat: String + ): Result = withContext(Dispatchers.IO) { + try { + val nonce = System.currentTimeMillis() + val signature = createSignature(nonce) + + val formBody = FormBody.Builder() + .add("clientId", clientId) + .add("publicKey", credentials.apiKey) + .add("nonce", nonce.toString()) + .add("signature", signature) + .add("orderId", orderId) + .build() + + val request = Request.Builder() + .url("$baseUrl/cancelOrder") + .post(formBody) + .build() + + client.newCall(request).execute().use { response -> + val body = response.body?.string() + ?: return@withContext Result.failure(java.io.IOException("Empty response")) + + if (!response.isSuccessful) { + return@withContext Result.failure(java.io.IOException("HTTP ${response.code}")) + } + + val json = JSONObject(body) + if (json.optBoolean("error", true)) { + val errorMessage = json.optString("errorMessage", "Cancel failed") + return@withContext Result.failure(java.io.IOException(errorMessage)) + } + + Result.success(Unit) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun getOrderStatus( + orderId: String, + crypto: String, + fiat: String + ): OrderStatusResult? = withContext(Dispatchers.IO) { + try { + val nonce = System.currentTimeMillis() + val signature = createSignature(nonce) + + val formBody = FormBody.Builder() + .add("clientId", clientId) + .add("publicKey", credentials.apiKey) + .add("nonce", nonce.toString()) + .add("signature", signature) + .add("orderId", orderId) + .build() + + val request = Request.Builder() + .url("$baseUrl/orderById") + .post(formBody) + .build() + + client.newCall(request).execute().use { response -> + val body = response.body?.string() ?: return@withContext null + if (!response.isSuccessful) return@withContext null + + val json = JSONObject(body) + if (json.optBoolean("error", true)) return@withContext null + + val data = json.optJSONObject("data") ?: return@withContext null + val status = data.optString("status", "") + + val originalAmount = BigDecimal(data.optString("originalAmount", "0")) + val remainingAmount = BigDecimal(data.optString("remainingAmount", "0")) + val avgPriceStr = data.optString("avgPrice", "") + val avgPrice = if (avgPriceStr.isNotEmpty()) { + try { BigDecimal(avgPriceStr) } catch (_: Exception) { null } + } else null + + val filledAmount = (originalAmount - remainingAmount).max(BigDecimal.ZERO) + val filledFiat = if (avgPrice != null && filledAmount > BigDecimal.ZERO) { + filledAmount.multiply(avgPrice).setScale(2, RoundingMode.HALF_UP) + } else BigDecimal.ZERO + + val mappedStatus = when (status.uppercase()) { + "FILLED" -> TransactionStatus.COMPLETED + "OPEN" -> if (filledAmount > BigDecimal.ZERO) { + TransactionStatus.PARTIAL + } else TransactionStatus.PENDING + "PARTIALLY_FILLED" -> TransactionStatus.PARTIAL + "CANCELLED", "CANCELED", "EXPIRED" -> + if (filledAmount > BigDecimal.ZERO) TransactionStatus.PARTIAL + else TransactionStatus.FAILED + else -> return@withContext null + } + + OrderStatusResult( + status = mappedStatus, + filledCryptoAmount = filledAmount, + filledFiatAmount = filledFiat, + avgFillPrice = if (filledAmount > BigDecimal.ZERO) avgPrice else null, + // Coinmate doesn't return aggregate fee on orderById - caller can fetch from tradeHistory if needed + fee = null, + feeAsset = null + ) + } + } catch (_: Exception) { + null + } + } + override suspend fun getBalance(currency: String): BigDecimal? = withContext(Dispatchers.IO) { try { val nonce = System.currentTimeMillis() From 222d69be1f1d58f6faacb2f4b86c9fc1d2cda5a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 22:17:31 +0200 Subject: [PATCH 08/75] feat(sell): implement BinanceApi limitSell + cancelOrder + getOrderStatus Task 11: - supportsLimitSell = true - limitSell: POST /api/v3/order se side=SELL, type=LIMIT, timeInForce=GTC, quantity+price; sdili stejny signing pattern jako marketBuy - cancelOrder: DELETE /api/v3/order s symbol+orderId - getOrderStatus: GET /api/v3/order, mapuje NEW/PARTIALLY_FILLED/FILLED/CANCELED/ EXPIRED/REJECTED/PENDING_* -> TransactionStatus - avgFillPrice = cummulativeQuoteQty / executedQty (pokud > 0) - Binance vraci fee per-fill, ne na order level; pro MVP vraci null v OrderStatusResult (dohledani pres /myTrades je volitelne v budoucnu) --- .../com/accbot/dca/exchange/BinanceApi.kt | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt index 7f74bf5..06e1042 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt @@ -29,6 +29,8 @@ class BinanceApi( override val exchange = Exchange.BINANCE + override val supportsLimitSell: Boolean = true + private val baseUrl = ExchangeConfig.getBaseUrl(Exchange.BINANCE, isSandbox) /** Offset in ms: serverTime - localTime. Add to System.currentTimeMillis() to get server time. */ @@ -180,6 +182,203 @@ class BinanceApi( } } + override suspend fun limitSell( + crypto: String, + fiat: String, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal + ): DcaResult = withContext(Dispatchers.IO) { + try { + ensureTimeSynced() + val symbol = "$crypto$fiat" + val timestamp = serverTimestamp() + + val params = buildString { + append("symbol=$symbol") + append("&side=SELL") + append("&type=LIMIT") + append("&timeInForce=GTC") + append("&quantity=${cryptoAmount.setScale(8, RoundingMode.DOWN).toPlainString()}") + append("&price=${limitPrice.setScale(2, RoundingMode.HALF_UP).toPlainString()}") + append("×tamp=$timestamp") + append("&recvWindow=60000") + } + + val signature = CryptoUtils.hmacSha256Hex(params, credentials.apiSecret) + val signedParams = "$params&signature=$signature" + + val request = Request.Builder() + .url("$baseUrl/api/v3/order?$signedParams") + .header("X-MBX-APIKEY", credentials.apiKey) + .post("".toRequestBody()) + .build() + + client.newCall(request).execute().use { response -> + val body = response.body?.string() ?: throw Exception("Empty response") + + if (!response.isSuccessful) { + val isRetryable = response.code in 500..599 || response.code == 429 + val errorMsg = try { + JSONObject(body).optString("msg", "HTTP ${response.code}") + } catch (_: Exception) { "HTTP ${response.code}" } + return@withContext DcaResult.Error(errorMsg, retryable = isRetryable) + } + + val json = JSONObject(body) + + if (json.has("code")) { + val errorMessage = json.optString("msg", "Unknown error") + return@withContext DcaResult.Error(errorMessage, retryable = false) + } + + // Binance returns orderId as a Long; convert to String for our domain + val orderId = json.get("orderId").toString() + + DcaResult.Success( + Transaction( + planId = 0, // caller fills in + connectionId = null, // caller fills in + exchange = Exchange.BINANCE, + crypto = crypto, + fiat = fiat, + fiatAmount = BigDecimal.ZERO, + cryptoAmount = BigDecimal.ZERO, + price = limitPrice, + fee = BigDecimal.ZERO, + feeAsset = "", + status = TransactionStatus.PENDING, + exchangeOrderId = orderId, + executedAt = Instant.now(), + side = TransactionSide.SELL, + limitPrice = limitPrice, + requestedCryptoAmount = cryptoAmount + ) + ) + } + } catch (e: java.io.IOException) { + DcaResult.Error(e.message ?: "Network error", retryable = true) + } catch (e: Exception) { + DcaResult.Error(e.message ?: "Unknown error", retryable = false) + } + } + + override suspend fun cancelOrder( + orderId: String, + crypto: String, + fiat: String + ): Result = withContext(Dispatchers.IO) { + try { + ensureTimeSynced() + val symbol = "$crypto$fiat" + val timestamp = serverTimestamp() + + val params = buildString { + append("symbol=$symbol") + append("&orderId=$orderId") + append("×tamp=$timestamp") + append("&recvWindow=60000") + } + + val signature = CryptoUtils.hmacSha256Hex(params, credentials.apiSecret) + val signedParams = "$params&signature=$signature" + + val request = Request.Builder() + .url("$baseUrl/api/v3/order?$signedParams") + .header("X-MBX-APIKEY", credentials.apiKey) + .delete() + .build() + + client.newCall(request).execute().use { response -> + val body = response.body?.string() + ?: return@withContext Result.failure(java.io.IOException("Empty response")) + + if (!response.isSuccessful) { + val errorMsg = try { + JSONObject(body).optString("msg", "HTTP ${response.code}") + } catch (_: Exception) { "HTTP ${response.code}" } + return@withContext Result.failure(java.io.IOException(errorMsg)) + } + + val json = JSONObject(body) + if (json.has("code")) { + val errorMessage = json.optString("msg", "Cancel failed") + return@withContext Result.failure(java.io.IOException(errorMessage)) + } + + Result.success(Unit) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun getOrderStatus( + orderId: String, + crypto: String, + fiat: String + ): OrderStatusResult? = withContext(Dispatchers.IO) { + try { + ensureTimeSynced() + val symbol = "$crypto$fiat" + val timestamp = serverTimestamp() + + val params = buildString { + append("symbol=$symbol") + append("&orderId=$orderId") + append("×tamp=$timestamp") + append("&recvWindow=60000") + } + + val signature = CryptoUtils.hmacSha256Hex(params, credentials.apiSecret) + + val request = Request.Builder() + .url("$baseUrl/api/v3/order?$params&signature=$signature") + .header("X-MBX-APIKEY", credentials.apiKey) + .get() + .build() + + client.newCall(request).execute().use { response -> + val body = response.body?.string() ?: return@withContext null + if (!response.isSuccessful) return@withContext null + + val json = JSONObject(body) + if (json.has("code")) return@withContext null + + val status = json.optString("status", "") + val executedQty = BigDecimal(json.optString("executedQty", "0")) + val cummulativeQuoteQty = BigDecimal(json.optString("cummulativeQuoteQty", "0")) + + val avgFillPrice = if (executedQty > BigDecimal.ZERO) { + cummulativeQuoteQty.divide(executedQty, 8, RoundingMode.HALF_UP) + } else null + + val mappedStatus = when (status) { + "FILLED" -> TransactionStatus.COMPLETED + "NEW", "PENDING_NEW" -> if (executedQty > BigDecimal.ZERO) { + TransactionStatus.PARTIAL + } else TransactionStatus.PENDING + "PARTIALLY_FILLED" -> TransactionStatus.PARTIAL + "CANCELED", "CANCELLED", "EXPIRED", "REJECTED", "PENDING_CANCEL" -> + if (executedQty > BigDecimal.ZERO) TransactionStatus.PARTIAL + else TransactionStatus.FAILED + else -> return@withContext null + } + + OrderStatusResult( + status = mappedStatus, + filledCryptoAmount = executedQty, + filledFiatAmount = cummulativeQuoteQty, + avgFillPrice = avgFillPrice, + // Binance fees are per-trade (need /api/v3/myTrades). MVP: leave null. + fee = null, + feeAsset = null + ) + } + } catch (_: Exception) { + null + } + } + override suspend fun getBalance(currency: String): BigDecimal? = withContext(Dispatchers.IO) { try { ensureTimeSynced() From 03994d34e24fcd6c6b4f06a1756d31e387cc8b96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 22:27:24 +0200 Subject: [PATCH 09/75] feat(sell): extend ResolvePendingTransactionsUseCase for SELL orders (Task 12) Fix broken build: switch to getResolvablePendingTransactions() + guarded updateResolvedTransaction() so the resolver handles both PENDING/PARTIAL BUY and SELL transactions and never clobbers a concurrent user cancel. Co-Authored-By: Claude Opus 4.7 --- .../ResolvePendingTransactionsUseCase.kt | 41 ++++++++----------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt index acb5508..facccba 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt @@ -8,12 +8,15 @@ import com.accbot.dca.exchange.ExchangeApiFactory import javax.inject.Inject /** - * Resolves PENDING transactions by querying exchange APIs for fill details. + * Resolves PENDING/PARTIAL transactions by querying exchange APIs for fill details. * - * When Kraken or Coinbase returns a PENDING status (fill details not available within - * the initial 3-second polling window), the transaction is saved with cryptoAmount=0. - * This use case finds those transactions and queries the exchange to get the actual - * fill details, updating them to COMPLETED with real values. + * Two scenarios produce resolvable rows: + * - BUY: Kraken/Coinbase sometimes return PENDING at place-order time because fill + * details weren't available within the initial 3-second polling window. + * - SELL: limit sell orders are always PENDING until (partially) filled or cancelled. + * + * Uses the guarded [TransactionDao.updateResolvedTransaction] UPDATE so a concurrent + * user cancel (which sets status=FAILED) is never clobbered. */ class ResolvePendingTransactionsUseCase @Inject constructor( private val database: DcaDatabase, @@ -22,7 +25,7 @@ class ResolvePendingTransactionsUseCase @Inject constructor( private val userPreferences: UserPreferences ) { suspend operator fun invoke(): Int { - val pendingTransactions = database.transactionDao().getPendingTransactionsWithOrderId() + val pendingTransactions = database.transactionDao().getResolvablePendingTransactions() if (pendingTransactions.isEmpty()) return 0 val isSandbox = userPreferences.isSandboxMode() @@ -30,9 +33,6 @@ class ResolvePendingTransactionsUseCase @Inject constructor( for (tx in pendingTransactions) { try { - // Use the transaction's connectionId (set by migration); fall back to a - // legacy lookup by exchange enum if it's null (very old transactions - // somehow not backfilled by v18→v19 migration). @Suppress("DEPRECATION") val credentials = if (tx.connectionId != null) { credentialsStore.getCredentials(tx.connectionId, isSandbox) @@ -42,21 +42,17 @@ class ResolvePendingTransactionsUseCase @Inject constructor( val api = exchangeApiFactory.create(credentials) val orderId = tx.exchangeOrderId ?: continue - val filledOrder = api.getOrderStatus(orderId) ?: continue + val result = api.getOrderStatus(orderId, tx.crypto, tx.fiat) ?: continue - // Update the transaction with real fill details - val updatedTx = tx.copy( - cryptoAmount = filledOrder.cryptoAmount, - fiatAmount = filledOrder.fiatAmount, - price = filledOrder.price, - fee = filledOrder.fee, - status = filledOrder.status + val rows = database.transactionDao().updateResolvedTransaction( + id = tx.id, + newStatus = result.status, + cryptoAmount = result.filledCryptoAmount, + fiatAmount = result.filledFiatAmount, + price = result.avgFillPrice ?: tx.price, + fee = result.fee ?: tx.fee ) - database.transactionDao().updateTransaction(updatedTx) - resolvedCount++ - - Log.d(TAG, "Resolved pending transaction ${tx.id}: " + - "${updatedTx.cryptoAmount} ${tx.crypto} for ${updatedTx.fiatAmount} ${tx.fiat}") + if (rows > 0) resolvedCount++ } catch (e: Exception) { Log.w(TAG, "Failed to resolve pending transaction ${tx.id}", e) } @@ -65,7 +61,6 @@ class ResolvePendingTransactionsUseCase @Inject constructor( if (resolvedCount > 0) { Log.d(TAG, "Resolved $resolvedCount/${pendingTransactions.size} pending transactions") } - return resolvedCount } From 12de4199f27c6dbd11a100d386726a69135b0140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 22:36:06 +0200 Subject: [PATCH 10/75] feat(sell): add sell-extension use cases and PlanPnL model (Tasks 13-16) - PlaceLimitSellUseCase: places a limit sell via ExchangeApi, inserts PENDING SELL transaction, best-effort resolves for instant-fill case - CancelSellOrderUseCase: cancels on exchange, marks local as PARTIAL if partially filled or FAILED otherwise; re-resolves on cancel failure - PlanPnL model + CalculatePlanPnLUseCase: per-plan realized / unrealized / net PnL + target progress - ValidateSellOrderUseCase: amount/limitPrice/min-order/available-crypto hard errors + instant-fill and far-from-market warnings - Add suspend TransactionDao.getTransactionsByPlanSync for PnL + validation (existing Flow variant stays for UI consumers) Co-Authored-By: Claude Opus 4.7 --- .../java/com/accbot/dca/data/local/Daos.kt | 7 ++ .../com/accbot/dca/domain/model/PlanPnL.kt | 36 +++++++ .../domain/usecase/CalculatePlanPnLUseCase.kt | 75 ++++++++++++++ .../domain/usecase/CancelSellOrderUseCase.kt | 64 ++++++++++++ .../domain/usecase/PlaceLimitSellUseCase.kt | 66 +++++++++++++ .../usecase/ValidateSellOrderUseCase.kt | 97 +++++++++++++++++++ 6 files changed, 345 insertions(+) create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/domain/model/PlanPnL.kt create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanPnLUseCase.kt create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CancelSellOrderUseCase.kt create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLimitSellUseCase.kt create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt index b843eb4..8e3507d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt @@ -210,6 +210,13 @@ interface TransactionDao { @Query("SELECT * FROM transactions WHERE planId = :planId ORDER BY executedAt DESC") fun getTransactionsByPlan(planId: Long): Flow> + /** + * One-shot snapshot of all transactions for a plan. Used by PnL / validation + * logic where a Flow isn't practical (suspend call from use case). + */ + @Query("SELECT * FROM transactions WHERE planId = :planId ORDER BY executedAt DESC") + suspend fun getTransactionsByPlanSync(planId: Long): List + @Query("SELECT * FROM transactions WHERE crypto = :crypto ORDER BY executedAt DESC") fun getTransactionsByCrypto(crypto: String): Flow> diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/PlanPnL.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/PlanPnL.kt new file mode 100644 index 0000000..63576b7 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/PlanPnL.kt @@ -0,0 +1,36 @@ +package com.accbot.dca.domain.model + +import java.math.BigDecimal + +/** + * Profit & loss snapshot for a single DCA plan. + * + * Derived fields (the ones ending in `?`) are null when the inputs to compute them + * aren't available - e.g. `currentValueFiat` is null when the caller didn't provide a + * live market price, `avgBuyPrice` is null when the plan has no completed BUYs yet. + * + * @property totalBoughtFiat Sum of fiat spent on COMPLETED/PARTIAL BUYs. + * @property totalBoughtCrypto Sum of crypto filled on COMPLETED/PARTIAL BUYs. + * @property totalSoldFiat Sum of fiat received from COMPLETED/PARTIAL SELLs (filled). + * @property totalSoldCrypto Sum of crypto delivered on COMPLETED/PARTIAL SELLs (filled). + * @property currentCryptoHeld totalBoughtCrypto - totalSoldCrypto. + * @property avgBuyPrice Volume-weighted avg buy price (fiat per crypto), or null if no buys. + * @property currentValueFiat currentCryptoHeld * currentMarketPrice, or null if no spot given. + * @property realizedPnL totalSoldFiat - (totalSoldCrypto * avgBuyPrice), or null. + * @property unrealizedPnL currentValueFiat - (currentCryptoHeld * avgBuyPrice), or null. + * @property netPnL realizedPnL + unrealizedPnL, or null. + * @property targetProgressPct netPnL / plan.targetProfitAmount as 0..1+ ratio, or null. + */ +data class PlanPnL( + val totalBoughtFiat: BigDecimal, + val totalBoughtCrypto: BigDecimal, + val totalSoldFiat: BigDecimal, + val totalSoldCrypto: BigDecimal, + val currentCryptoHeld: BigDecimal, + val avgBuyPrice: BigDecimal?, + val currentValueFiat: BigDecimal?, + val realizedPnL: BigDecimal?, + val unrealizedPnL: BigDecimal?, + val netPnL: BigDecimal?, + val targetProgressPct: Double? +) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanPnLUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanPnLUseCase.kt new file mode 100644 index 0000000..d448d68 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanPnLUseCase.kt @@ -0,0 +1,75 @@ +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.domain.model.PlanPnL +import com.accbot.dca.domain.model.TransactionSide +import com.accbot.dca.domain.model.TransactionStatus +import java.math.BigDecimal +import java.math.RoundingMode +import javax.inject.Inject + +/** + * Compute [PlanPnL] from the plan's COMPLETED/PARTIAL transactions. + * + * Only counts filled amounts (`cryptoAmount` / `fiatAmount`) on SELLs - open (PENDING) + * SELLs do not affect realized PnL; they only reduce the effective free crypto in + * [ValidateSellOrderUseCase]. + */ +class CalculatePlanPnLUseCase @Inject constructor( + private val database: DcaDatabase +) { + suspend operator fun invoke( + planId: Long, + currentMarketPrice: BigDecimal? + ): PlanPnL { + val plan = database.dcaPlanDao().getPlanById(planId) + ?: error("Plan $planId neexistuje") + + val transactions = database.transactionDao().getTransactionsByPlanSync(planId) + + val relevant = transactions.filter { + it.status == TransactionStatus.COMPLETED || it.status == TransactionStatus.PARTIAL + } + + val buys = relevant.filter { it.side == TransactionSide.BUY } + val sells = relevant.filter { it.side == TransactionSide.SELL } + + val totalBoughtFiat = buys.fold(BigDecimal.ZERO) { acc, tx -> acc + tx.fiatAmount } + val totalBoughtCrypto = buys.fold(BigDecimal.ZERO) { acc, tx -> acc + tx.cryptoAmount } + val totalSoldFiat = sells.fold(BigDecimal.ZERO) { acc, tx -> acc + tx.fiatAmount } + val totalSoldCrypto = sells.fold(BigDecimal.ZERO) { acc, tx -> acc + tx.cryptoAmount } + val currentCryptoHeld = totalBoughtCrypto - totalSoldCrypto + + val avgBuyPrice = if (totalBoughtCrypto > BigDecimal.ZERO) { + totalBoughtFiat.divide(totalBoughtCrypto, 8, RoundingMode.HALF_UP) + } else null + + val currentValueFiat = currentMarketPrice?.let { currentCryptoHeld * it } + val realizedPnL = avgBuyPrice?.let { totalSoldFiat - (totalSoldCrypto * it) } + val unrealizedPnL = if (avgBuyPrice != null && currentValueFiat != null) { + currentValueFiat - (currentCryptoHeld * avgBuyPrice) + } else null + val netPnL = if (realizedPnL != null && unrealizedPnL != null) { + realizedPnL + unrealizedPnL + } else null + + val target = plan.targetProfitAmount + val targetProgressPct = if (netPnL != null && target != null && target > BigDecimal.ZERO) { + netPnL.toDouble() / target.toDouble() + } else null + + return PlanPnL( + totalBoughtFiat = totalBoughtFiat, + totalBoughtCrypto = totalBoughtCrypto, + totalSoldFiat = totalSoldFiat, + totalSoldCrypto = totalSoldCrypto, + currentCryptoHeld = currentCryptoHeld, + avgBuyPrice = avgBuyPrice, + currentValueFiat = currentValueFiat, + realizedPnL = realizedPnL, + unrealizedPnL = unrealizedPnL, + netPnL = netPnL, + targetProgressPct = targetProgressPct + ) + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CancelSellOrderUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CancelSellOrderUseCase.kt new file mode 100644 index 0000000..711aee0 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CancelSellOrderUseCase.kt @@ -0,0 +1,64 @@ +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.domain.model.TransactionStatus +import com.accbot.dca.exchange.ExchangeApiFactory +import java.math.BigDecimal +import javax.inject.Inject + +/** + * Cancel an open limit sell order. + * + * On exchange-cancel success locally marks the transaction as: + * - PARTIAL if some fills already happened (cryptoAmount > 0) + * - FAILED otherwise + * + * On exchange-cancel failure, tries to re-resolve the order status so the UI reflects + * the true state (the order may have filled between the user pressing cancel and the + * exchange receiving the request). + */ +class CancelSellOrderUseCase @Inject constructor( + private val database: DcaDatabase, + private val credentialsStore: CredentialsStore, + private val exchangeApiFactory: ExchangeApiFactory, + private val userPreferences: UserPreferences, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase +) { + suspend operator fun invoke(txId: Long): Result { + val tx = database.transactionDao().getTransactionById(txId) + ?: return Result.failure(IllegalArgumentException("Transakce $txId neexistuje")) + + val orderId = tx.exchangeOrderId + ?: return Result.failure(IllegalStateException("Transakce nema exchangeOrderId")) + + val credentials = tx.connectionId?.let { + credentialsStore.getCredentials(it, userPreferences.isSandboxMode()) + } ?: return Result.failure(IllegalStateException("Chybi credentials")) + + val api = exchangeApiFactory.create(credentials) + val cancelResult = api.cancelOrder(orderId, tx.crypto, tx.fiat) + + return if (cancelResult.isSuccess) { + val newStatus = if (tx.cryptoAmount > BigDecimal.ZERO) + TransactionStatus.PARTIAL else TransactionStatus.FAILED + database.transactionDao().updateResolvedTransaction( + id = txId, + newStatus = newStatus, + cryptoAmount = tx.cryptoAmount, + fiatAmount = tx.fiatAmount, + price = tx.price, + fee = tx.fee + ) + Result.success(Unit) + } else { + try { + resolvePendingTransactionsUseCase() + } catch (_: Exception) { + // Best effort. + } + cancelResult + } + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLimitSellUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLimitSellUseCase.kt new file mode 100644 index 0000000..31e4b15 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLimitSellUseCase.kt @@ -0,0 +1,66 @@ +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.data.local.toEntity +import com.accbot.dca.domain.model.DcaResult +import com.accbot.dca.exchange.ExchangeApiFactory +import java.math.BigDecimal +import javax.inject.Inject + +/** + * Place a limit sell order for an existing DCA plan. + * + * On success inserts a PENDING SELL transaction (filled fields=0, requestedCryptoAmount + * preserved) and returns the new row id. The caller (UI / worker) is expected to refresh + * the open-sells list after. + * + * Also kicks off the resolver best-effort after insert so if the order filled instantly + * it's already marked COMPLETED when the UI reads back. + */ +class PlaceLimitSellUseCase @Inject constructor( + private val database: DcaDatabase, + private val credentialsStore: CredentialsStore, + private val exchangeApiFactory: ExchangeApiFactory, + private val userPreferences: UserPreferences, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase +) { + suspend operator fun invoke( + planId: Long, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal + ): Result { + val plan = database.dcaPlanDao().getPlanById(planId) + ?: return Result.failure(IllegalArgumentException("Plan $planId neexistuje")) + + val credentials = credentialsStore.getCredentials( + plan.connectionId, + userPreferences.isSandboxMode() + ) ?: return Result.failure( + IllegalStateException("Chybi credentials pro connection ${plan.connectionId}") + ) + + val api = exchangeApiFactory.create(credentials) + val result = api.limitSell(plan.crypto, plan.fiat, cryptoAmount, limitPrice) + + return when (result) { + is DcaResult.Success -> { + val tx = result.transaction.copy( + planId = planId, + connectionId = plan.connectionId + ) + val txId = database.transactionDao().insertTransaction(tx.toEntity()) + try { + resolvePendingTransactionsUseCase() + } catch (_: Exception) { + // Best effort - resolver runs periodically anyway. + } + Result.success(txId) + } + is DcaResult.Error -> Result.failure( + IllegalStateException(result.message) + ) + } + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt new file mode 100644 index 0000000..0095679 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt @@ -0,0 +1,97 @@ +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.domain.model.TransactionSide +import com.accbot.dca.domain.model.TransactionStatus +import java.math.BigDecimal +import javax.inject.Inject + +/** + * Validation outcome for a prospective sell order. Multiple items may be returned + * (e.g. InstantFillInfo and no hard error), so callers should render each item. + * [Ok] is only emitted when the list would otherwise be empty. + */ +sealed class SellValidation { + object Ok : SellValidation() + data class HardError(val message: String) : SellValidation() + data class InstantFillInfo(val spot: BigDecimal) : SellValidation() + data class FarFromMarketWarning(val spot: BigDecimal) : SellValidation() +} + +/** + * Validate a prospective limit sell order against plan state (held crypto minus + * reservations from open sells) and optional spot price. + * + * Checks: + * - amount > 0, limitPrice > 0 + * - amount >= minOrderSize (exchange-specific, passed by caller) + * - amount <= available crypto (held - unfilled reservations on other open sells) + * - limitPrice <= spot -> InstantFillInfo (UI shows warning; order will fill immediately) + * - limitPrice > 3x spot -> FarFromMarketWarning (typo protection) + */ +class ValidateSellOrderUseCase @Inject constructor( + private val database: DcaDatabase +) { + suspend operator fun invoke( + planId: Long, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal, + minOrderSize: BigDecimal, + currentSpot: BigDecimal? + ): List { + val result = mutableListOf() + + if (cryptoAmount <= BigDecimal.ZERO) { + result += SellValidation.HardError("Mnozstvi musi byt vetsi nez 0") + return result + } + if (limitPrice <= BigDecimal.ZERO) { + result += SellValidation.HardError("Limitni cena musi byt vetsi nez 0") + return result + } + if (cryptoAmount < minOrderSize) { + result += SellValidation.HardError("Minimalni order je $minOrderSize") + } + + val tx = database.transactionDao().getTransactionsByPlanSync(planId) + val completedOrPartial = tx.filter { + it.status == TransactionStatus.COMPLETED || it.status == TransactionStatus.PARTIAL + } + val heldBought = completedOrPartial + .filter { it.side == TransactionSide.BUY } + .fold(BigDecimal.ZERO) { acc, t -> acc + t.cryptoAmount } + val heldSold = completedOrPartial + .filter { it.side == TransactionSide.SELL } + .fold(BigDecimal.ZERO) { acc, t -> acc + t.cryptoAmount } + val held = heldBought - heldSold + + // Unfilled crypto reserved by other open sells (PENDING or PARTIAL). + val openSellsRequested = tx + .filter { + it.side == TransactionSide.SELL && + it.status in setOf(TransactionStatus.PENDING, TransactionStatus.PARTIAL) + } + .fold(BigDecimal.ZERO) { acc, t -> + acc + ((t.requestedCryptoAmount ?: BigDecimal.ZERO) - t.cryptoAmount) + } + + val available = held - openSellsRequested + if (cryptoAmount > available) { + result += SellValidation.HardError( + "Nemas tolik k dispozici (k dispozici $available)" + ) + } + + if (currentSpot != null) { + if (limitPrice <= currentSpot) { + result += SellValidation.InstantFillInfo(currentSpot) + } + if (limitPrice > currentSpot.multiply(BigDecimal("3"))) { + result += SellValidation.FarFromMarketWarning(currentSpot) + } + } + + if (result.isEmpty()) result += SellValidation.Ok + return result + } +} From 7434913d429b85bf7fb2f5c6d553f35e53f4b2f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 22:41:47 +0200 Subject: [PATCH 11/75] feat(sell): UserPreferences trading + sell polling flags (Task 17) Adds persisted prefs for the upcoming sell extension: a master isTradingEnabled switch (default off) and a group for periodic sell-order polling (enabled flag, DcaFrequency with cron + schedule-config for CUSTOM). All sell-polling fields are written atomically via setPeriodicSellPolling to avoid readers seeing a half-applied state. Enum parsing falls back to HOURLY on unknown names. Co-Authored-By: Claude Opus 4.6 --- .../accbot/dca/data/local/UserPreferences.kt | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt index 0f428b4..40972c7 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt @@ -2,6 +2,7 @@ package com.accbot.dca.data.local import android.content.Context import android.content.SharedPreferences +import com.accbot.dca.domain.model.DcaFrequency import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -226,6 +227,87 @@ class UserPreferences @Inject constructor( prefs.edit().putString(KEY_PORTFOLIO_SELECTED_PAGE, pageId).apply() } + // ==================== Trading (Sell Extension) ==================== + + /** + * Master trading switch. When false, all sell-order flows (take-profit, + * trailing, manual limit-sell) are disabled regardless of per-plan settings. + * Defaults to false so upgrading users must explicitly opt in. + */ + fun isTradingEnabled(): Boolean { + return prefs.getBoolean(KEY_TRADING_ENABLED, false) + } + + fun setTradingEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_TRADING_ENABLED, enabled).apply() + } + + // ==================== Sell Polling ==================== + + /** + * Whether the background [SellPollingWorker] is enabled. + * Independent from [isTradingEnabled] so users can keep polling on for + * historical fills even after disabling new sell orders, and off by default. + */ + fun isPeriodicSellPollingEnabled(): Boolean { + return prefs.getBoolean(KEY_SELL_POLLING_ENABLED, false) + } + + /** + * Polling frequency. For CUSTOM, [getSellPollingCronExpression] provides + * the cron string. Falls back to HOURLY if the stored enum name can't be + * parsed (e.g. after downgrading from a build that added a new frequency). + */ + fun getSellPollingFrequency(): DcaFrequency { + val name = prefs.getString(KEY_SELL_POLLING_FREQUENCY, DcaFrequency.HOURLY.name) + ?: return DcaFrequency.HOURLY + return try { + DcaFrequency.valueOf(name) + } catch (e: IllegalArgumentException) { + DcaFrequency.HOURLY + } + } + + /** + * Cron expression used when [getSellPollingFrequency] returns CUSTOM. + * Null for preset frequencies. + */ + fun getSellPollingCronExpression(): String? { + return prefs.getString(KEY_SELL_POLLING_CRON, null) + } + + /** + * Serialized visual schedule builder state (JSON). Null for preset frequencies. + * The worker ignores this - it's only used by the UI to re-hydrate the + * schedule picker without having to reverse-engineer the cron string. + */ + fun getSellPollingScheduleConfig(): String? { + return prefs.getString(KEY_SELL_POLLING_SCHEDULE_CONFIG, null) + } + + /** + * Update all sell-polling settings in one edit so readers never see a + * half-applied state (e.g. CUSTOM frequency with a stale cron from a + * previous save). + */ + fun setPeriodicSellPolling( + enabled: Boolean, + frequency: DcaFrequency, + cron: String?, + scheduleConfig: String? + ) { + prefs.edit() + .putBoolean(KEY_SELL_POLLING_ENABLED, enabled) + .putString(KEY_SELL_POLLING_FREQUENCY, frequency.name) + .apply { + if (cron != null) putString(KEY_SELL_POLLING_CRON, cron) + else remove(KEY_SELL_POLLING_CRON) + if (scheduleConfig != null) putString(KEY_SELL_POLLING_SCHEDULE_CONFIG, scheduleConfig) + else remove(KEY_SELL_POLLING_SCHEDULE_CONFIG) + } + .apply() + } + companion object { private const val PREFS_NAME = "accbot_user_prefs" private const val KEY_APP_THEME = "app_theme" @@ -242,5 +324,10 @@ class UserPreferences @Inject constructor( private const val KEY_MARKET_PULSE_EXPANDED = "market_pulse_expanded" private const val KEY_EXPERIMENTAL_EXCHANGES = "experimental_exchanges_enabled" private const val KEY_PORTFOLIO_SELECTED_PAGE = "portfolio_selected_page" + private const val KEY_TRADING_ENABLED = "trading_enabled" + private const val KEY_SELL_POLLING_ENABLED = "sell_polling_enabled" + private const val KEY_SELL_POLLING_FREQUENCY = "sell_polling_frequency" + private const val KEY_SELL_POLLING_CRON = "sell_polling_cron" + private const val KEY_SELL_POLLING_SCHEDULE_CONFIG = "sell_polling_schedule_config" } } From d90b07771a609f46243be255bb05927840050bf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 22:41:54 +0200 Subject: [PATCH 12/75] feat(sell): SellPollingWorker + Scheduler (Task 18) Introduces a self-perpetuating one-shot WorkManager chain for resolving PENDING sell orders in the background. The scheduler reads frequency and cron from UserPreferences, computes the next fire (via CronUtils for CUSTOM), and enqueues a unique work request with REPLACE policy. Worker short-circuits when there are no open sells, then always reschedules itself (even on failure) so transient errors don't stop the chain. Co-Authored-By: Claude Opus 4.6 --- .../accbot/dca/worker/SellPollingScheduler.kt | 85 +++++++++++++++++++ .../accbot/dca/worker/SellPollingWorker.kt | 54 ++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/worker/SellPollingScheduler.kt create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/worker/SellPollingWorker.kt diff --git a/accbot-android/app/src/main/java/com/accbot/dca/worker/SellPollingScheduler.kt b/accbot-android/app/src/main/java/com/accbot/dca/worker/SellPollingScheduler.kt new file mode 100644 index 0000000..c7a4ac8 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/worker/SellPollingScheduler.kt @@ -0,0 +1,85 @@ +package com.accbot.dca.worker + +import android.util.Log +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.domain.model.DcaFrequency +import com.accbot.dca.domain.util.CronUtils +import java.time.Duration +import java.time.Instant +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Schedules the self-perpetuating chain of [SellPollingWorker] runs. + * + * Each worker run calls [rescheduleIfEnabled] to enqueue the next one, which + * means the only way to stop polling is either [cancel] or toggling off the + * user preference. Using one-shot work with [ExistingWorkPolicy.REPLACE] gives + * us arbitrary cron-based intervals (and intervals shorter than WorkManager's + * 15-minute periodic minimum, e.g. EVERY_15_MIN still works, but anything + * tighter is also supported if a cron is provided). + */ +@Singleton +class SellPollingScheduler @Inject constructor( + private val workManager: WorkManager, + private val userPreferences: UserPreferences +) { + /** + * Enqueue the next poll if the user has polling enabled; otherwise cancel + * any outstanding chain. Safe to call from worker completion, app startup, + * and settings-change listeners. + */ + fun rescheduleIfEnabled() { + if (!userPreferences.isPeriodicSellPollingEnabled()) { + cancel() + return + } + + val frequency = userPreferences.getSellPollingFrequency() + val cron = userPreferences.getSellPollingCronExpression() + val now = Instant.now() + + val nextFire: Instant = when { + frequency == DcaFrequency.CUSTOM && cron != null -> { + CronUtils.getNextExecution(cron, now) + ?: now.plus(Duration.ofMinutes(60)) + } + else -> now.plus(Duration.ofMinutes(frequency.intervalMinutes)) + } + + val delayMs = (nextFire.toEpochMilli() - System.currentTimeMillis()).coerceAtLeast(0L) + + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(delayMs, TimeUnit.MILLISECONDS) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .addTag(SellPollingWorker.WORK_NAME) + .build() + + workManager.enqueueUniqueWork( + SellPollingWorker.WORK_NAME, + ExistingWorkPolicy.REPLACE, + request + ) + + Log.d(TAG, "SellPollingWorker scheduled for $nextFire (in ${delayMs}ms)") + } + + fun cancel() { + workManager.cancelUniqueWork(SellPollingWorker.WORK_NAME) + Log.d(TAG, "SellPollingWorker cancelled") + } + + companion object { + private const val TAG = "SellPollingScheduler" + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/worker/SellPollingWorker.kt b/accbot-android/app/src/main/java/com/accbot/dca/worker/SellPollingWorker.kt new file mode 100644 index 0000000..d7ce57a --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/worker/SellPollingWorker.kt @@ -0,0 +1,54 @@ +package com.accbot.dca.worker + +import android.content.Context +import android.util.Log +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.domain.usecase.ResolvePendingTransactionsUseCase +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +/** + * Periodic background worker that resolves PENDING sell orders by polling the + * exchange API. Scheduled/rescheduled by [SellPollingScheduler] using the + * user-configured frequency (preset or cron). + * + * The worker is a no-op (other than rescheduling) when there are no open sells, + * keeping battery impact minimal when trading is idle. + */ +@HiltWorker +class SellPollingWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val database: DcaDatabase, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase, + private val sellPollingScheduler: SellPollingScheduler +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + return try { + val openSells = database.transactionDao().countOpenSells() + if (openSells > 0) { + Log.d(TAG, "Resolving $openSells open sell(s)") + val resolved = resolvePendingTransactionsUseCase() + Log.d(TAG, "Resolved $resolved transaction(s)") + } else { + Log.d(TAG, "No open sells, skipping resolve") + } + sellPollingScheduler.rescheduleIfEnabled() + Result.success() + } catch (e: Exception) { + Log.w(TAG, "SellPollingWorker error", e) + // Always re-arm the chain so a transient failure doesn't stop polling forever. + try { sellPollingScheduler.rescheduleIfEnabled() } catch (_: Exception) {} + Result.retry() + } + } + + companion object { + private const val TAG = "SellPollingWorker" + const val WORK_NAME = "sell_polling" + } +} From ed4a532470bbf6744a4784895dee8dc82168dbc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 22:42:01 +0200 Subject: [PATCH 13/75] feat(sell): poll pending sells on app foreground (Task 19) Adds lifecycle-process dep and hooks a ProcessLifecycleOwner observer in AccBotApplication. On every app onStart we fire-and-forget a call to ResolvePendingTransactionsUseCase from an app-scope SupervisorJob so users see their filled sells reflect immediately when they open the app, independent of the periodic worker cadence. Errors are logged and swallowed - the use case is a no-op when there are no open sells. Co-Authored-By: Claude Opus 4.6 --- accbot-android/app/build.gradle.kts | 1 + .../java/com/accbot/dca/AccBotApplication.kt | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/accbot-android/app/build.gradle.kts b/accbot-android/app/build.gradle.kts index 6d0137f..c77465d 100644 --- a/accbot-android/app/build.gradle.kts +++ b/accbot-android/app/build.gradle.kts @@ -89,6 +89,7 @@ dependencies { implementation("androidx.core:core-ktx:1.17.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0") + implementation("androidx.lifecycle:lifecycle-process:2.10.0") implementation("androidx.activity:activity-compose:1.12.3") // Jetpack Compose diff --git a/accbot-android/app/src/main/java/com/accbot/dca/AccBotApplication.kt b/accbot-android/app/src/main/java/com/accbot/dca/AccBotApplication.kt index 947a8cc..96668c5 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/AccBotApplication.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/AccBotApplication.kt @@ -5,12 +5,19 @@ import android.util.Log import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import androidx.hilt.work.HiltWorkerFactory +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.Configuration import com.accbot.dca.data.local.CredentialsStore import com.accbot.dca.data.local.DcaDatabase import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.domain.usecase.ResolvePendingTransactionsUseCase import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import javax.inject.Inject @@ -31,6 +38,16 @@ class AccBotApplication : Application(), Configuration.Provider { @Inject lateinit var credentialsStore: CredentialsStore + @Inject + lateinit var resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase + + /** + * App-scope coroutine scope for work that outlives any single ViewModel or + * Activity (lifecycle-observer callbacks, one-off startup tasks). SupervisorJob + * so a single failure doesn't cancel the whole scope. + */ + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + override val workManagerConfiguration: Configuration get() = Configuration.Builder() .setWorkerFactory(workerFactory) @@ -69,6 +86,22 @@ class AccBotApplication : Application(), Configuration.Provider { Log.e(TAG, "Failed to run CredentialsStore migration", e) } } + + // Poll for PENDING sell-order resolutions every time the app comes to the + // foreground. This gives users near-instant updates when they open the app + // after a sell has filled, without waiting for the next SellPollingWorker + // tick. Fire-and-forget; the use case is a no-op when there are no open sells. + ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + appScope.launch { + try { + resolvePendingTransactionsUseCase() + } catch (e: Exception) { + Log.w(TAG, "Polling on app start failed", e) + } + } + } + }) } companion object { From d1287df8a3ba877dcb96e1890421f9c6b80c41a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 22:57:51 +0200 Subject: [PATCH 14/75] feat(sell): Advanced section in Settings (Task 20) - Add trading master switch (setTradingEnabled) that also cancels sell polling when turned off. - Add background sell-order polling toggle with frequency picker (reuses FrequencyDropdown) and CUSTOM cron text field. - Disabling trading atomically clears all sell-polling prefs and cancels the SellPollingScheduler to avoid orphan work. - Settings strings localized for cs/en. Note: For DAILY/WEEKLY/CUSTOM the worker relies on DcaFrequency.intervalMinutes / cron (same as SellPollingScheduler already handles). The richer visual schedule builder used by AddPlanScreen is intentionally not reused here to keep the task scoped - a future refactor can extract it as a shared composable. Co-Authored-By: Claude Opus 4.6 --- .../presentation/screens/SettingsScreen.kt | 160 ++++++++++++++++++ .../presentation/screens/SettingsViewModel.kt | 56 +++++- .../app/src/main/res/values-cs/strings.xml | 24 +++ .../app/src/main/res/values/strings.xml | 24 +++ 4 files changed, 261 insertions(+), 3 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsScreen.kt index 63afca4..33edb5f 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsScreen.kt @@ -41,8 +41,10 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import com.accbot.dca.BuildConfig import com.accbot.dca.R import com.accbot.dca.data.local.AppTheme +import com.accbot.dca.domain.model.DcaFrequency import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.WithdrawalThreshold +import com.accbot.dca.presentation.components.FrequencyDropdown import java.math.BigDecimal import com.accbot.dca.presentation.changelog.ChangelogData import com.accbot.dca.presentation.components.AccBotTopAppBar @@ -690,6 +692,102 @@ fun SettingsScreen( ) } + // ── ADVANCED (Sell extension) ────────────────────────── + item { + Text( + text = stringResource(R.string.settings_advanced), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(top = 24.dp, bottom = 8.dp) + .semantics { heading() } + ) + } + + item { + TradingToggleCard( + isEnabled = uiState.tradingEnabled, + onToggle = { viewModel.setTradingEnabled(it) } + ) + } + + if (uiState.tradingEnabled) { + item { + SellPollingToggleCard( + isEnabled = uiState.periodicSellPollingEnabled, + onToggle = { enabled -> + viewModel.setPeriodicSellPolling( + enabled = enabled, + frequency = uiState.sellPollingFrequency, + cron = uiState.sellPollingCronExpression, + scheduleConfig = null + ) + } + ) + } + + if (uiState.periodicSellPollingEnabled) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.settings_sell_polling_frequency), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + FrequencyDropdown( + selectedFrequency = uiState.sellPollingFrequency, + onFrequencySelected = { newFreq -> + viewModel.setPeriodicSellPolling( + enabled = true, + frequency = newFreq, + cron = null, + scheduleConfig = null + ) + } + ) + if (uiState.sellPollingFrequency == DcaFrequency.CUSTOM) { + var cronInput by rememberSaveable { + mutableStateOf(uiState.sellPollingCronExpression ?: "") + } + OutlinedTextField( + value = cronInput, + onValueChange = { newValue -> + cronInput = newValue + viewModel.setPeriodicSellPolling( + enabled = true, + frequency = DcaFrequency.CUSTOM, + cron = newValue.ifBlank { null }, + scheduleConfig = null + ) + }, + label = { Text(stringResource(R.string.settings_sell_polling_cron_label)) }, + placeholder = { Text(stringResource(R.string.settings_sell_polling_cron_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + Text( + text = stringResource(R.string.settings_sell_polling_battery_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + // ── DANGER ZONE (collapsible) ────────────────────────── item { Row( @@ -1111,6 +1209,68 @@ private fun WithdrawalThresholdDialog( ) } +@Composable +internal fun TradingToggleCard( + isEnabled: Boolean, + onToggle: (Boolean) -> Unit +) { + val accent = successColor() + val haptic = LocalHapticFeedback.current + SettingsCardBase( + title = stringResource(R.string.settings_trading_enabled_title), + subtitle = stringResource(R.string.settings_trading_enabled_subtitle), + icon = Icons.AutoMirrored.Filled.TrendingUp, + iconTint = if (isEnabled) accent else MaterialTheme.colorScheme.onSurfaceVariant, + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onToggle(!isEnabled) + }, + cardModifier = Modifier.semantics(mergeDescendants = true) { role = Role.Switch }, + trailing = { + Switch( + checked = isEnabled, + onCheckedChange = null, + modifier = Modifier.clearAndSetSemantics {}, + colors = SwitchDefaults.colors( + checkedThumbColor = accent, + checkedTrackColor = accent.copy(alpha = 0.5f) + ) + ) + } + ) +} + +@Composable +internal fun SellPollingToggleCard( + isEnabled: Boolean, + onToggle: (Boolean) -> Unit +) { + val accent = successColor() + val haptic = LocalHapticFeedback.current + SettingsCardBase( + title = stringResource(R.string.settings_sell_polling_title), + subtitle = stringResource(R.string.settings_sell_polling_subtitle), + icon = Icons.Default.Sync, + iconTint = if (isEnabled) accent else MaterialTheme.colorScheme.onSurfaceVariant, + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onToggle(!isEnabled) + }, + cardModifier = Modifier.semantics(mergeDescendants = true) { role = Role.Switch }, + trailing = { + Switch( + checked = isEnabled, + onCheckedChange = null, + modifier = Modifier.clearAndSetSemantics {}, + colors = SwitchDefaults.colors( + checkedThumbColor = accent, + checkedTrackColor = accent.copy(alpha = 0.5f) + ) + ) + } + ) +} + @Composable internal fun SandboxToggleCard( isEnabled: Boolean, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsViewModel.kt index a5ee07e..42854c5 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsViewModel.kt @@ -21,6 +21,7 @@ import com.accbot.dca.data.local.WithdrawalDao import com.accbot.dca.data.local.WithdrawalThresholdDao import com.accbot.dca.data.local.WithdrawalThresholdEntity import com.accbot.dca.data.local.toDomain +import com.accbot.dca.domain.model.DcaFrequency import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.WithdrawalThreshold import java.math.BigDecimal @@ -28,6 +29,7 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import com.accbot.dca.service.DcaForegroundService import com.accbot.dca.worker.DcaWorker +import com.accbot.dca.worker.SellPollingScheduler import androidx.compose.runtime.Immutable import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.* @@ -61,7 +63,12 @@ data class SettingsUiState( val purchaseNotificationsEnabled: Boolean = true, val errorNotificationsEnabled: Boolean = true, val weeklySummaryEnabled: Boolean = false, - val isExperimentalExchangesEnabled: Boolean = false + val isExperimentalExchangesEnabled: Boolean = false, + // Sell-extension (Pokrocile) + val tradingEnabled: Boolean = false, + val periodicSellPollingEnabled: Boolean = false, + val sellPollingFrequency: DcaFrequency = DcaFrequency.HOURLY, + val sellPollingCronExpression: String? = null ) @HiltViewModel @@ -78,7 +85,8 @@ class SettingsViewModel @Inject constructor( private val dailyPriceDao: DailyPriceDao, private val withdrawalDao: WithdrawalDao, private val withdrawalThresholdDao: WithdrawalThresholdDao, - private val exchangeConnectionDao: ExchangeConnectionDao + private val exchangeConnectionDao: ExchangeConnectionDao, + private val sellPollingScheduler: SellPollingScheduler ) : AndroidViewModel(application) { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -133,7 +141,11 @@ class SettingsViewModel @Inject constructor( appTheme = userPreferences.getAppTheme(), isBiometricLockEnabled = userPreferences.isBiometricLockEnabled(), isMarketPulseEnabled = userPreferences.isMarketPulseEnabled(), - isExperimentalExchangesEnabled = userPreferences.areExperimentalExchangesEnabled() + isExperimentalExchangesEnabled = userPreferences.areExperimentalExchangesEnabled(), + tradingEnabled = userPreferences.isTradingEnabled(), + periodicSellPollingEnabled = userPreferences.isPeriodicSellPollingEnabled(), + sellPollingFrequency = userPreferences.getSellPollingFrequency(), + sellPollingCronExpression = userPreferences.getSellPollingCronExpression() ) } } @@ -198,6 +210,44 @@ class SettingsViewModel @Inject constructor( _uiState.update { it.copy(isMarketPulseEnabled = enabled) } } + /** + * Master trading switch. When turned off, also disables background sell polling + * and cancels the worker so leftover settings don't silently keep it alive. + */ + fun setTradingEnabled(enabled: Boolean) { + userPreferences.setTradingEnabled(enabled) + if (!enabled) { + userPreferences.setPeriodicSellPolling( + enabled = false, + frequency = DcaFrequency.HOURLY, + cron = null, + scheduleConfig = null + ) + sellPollingScheduler.cancel() + } + loadSettings() + } + + /** + * Enable / reschedule / disable periodic sell-order polling. Callers pass the full + * scheduling config in one shot (see [UserPreferences.setPeriodicSellPolling]) so + * readers never see a half-applied state. + */ + fun setPeriodicSellPolling( + enabled: Boolean, + frequency: DcaFrequency = _uiState.value.sellPollingFrequency, + cron: String? = null, + scheduleConfig: String? = null + ) { + userPreferences.setPeriodicSellPolling(enabled, frequency, cron, scheduleConfig) + if (enabled) { + sellPollingScheduler.rescheduleIfEnabled() + } else { + sellPollingScheduler.cancel() + } + loadSettings() + } + fun setBiometricLockEnabled(enabled: Boolean) { userPreferences.setBiometricLockEnabled(enabled) _uiState.update { it.copy(isBiometricLockEnabled = enabled) } diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 53d9ff2..9ad50cb 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -926,4 +926,28 @@ Opakovat + + + POKROČILÉ + Povolit prodeje + Umožní u vybraných plánů zadávat limitní prodejní příkazy a sledovat P&L. + Kontrolovat sell ordery na pozadí + Periodická kontrola stavu orderů. Zvyšuje spotřebu baterie. + Frekvence + Vlastní plán (CRON) + např. 0 *\/2 * * * + Časté kontroly zvyšují spotřebu baterie a počítají se do API limitů burzy. + + + Prodeje (volitelné) + Povolit prodeje pro tento plán + Cíl zisku (volitelné, v %1$s) + Detail plánu zobrazí progress bar k tomuto cíli. + Zadej platné číslo + Cíl musí být kladný + + + Vypnout prodeje? + Máš %1$d otevřených sell orderů. Vypnutím prodejů se skryje sell sekce, ale ordery na burze zůstávají. Musíš je zrušit ručně přes burzu, nebo zapnutím prodejů a kliknutím Cancel. + Vypnout diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 3eb68f2..2137057 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -920,4 +920,28 @@ Retry + + + ADVANCED + Enable sell orders + Allow selected plans to place limit sell orders and track P&L. + Check sell orders in background + Periodically check order status. Increases battery usage. + Frequency + Custom schedule (CRON) + e.g. 0 *\/2 * * * + Frequent checks increase battery usage and count against exchange API limits. + + + Sells (optional) + Enable sells for this plan + Profit target (optional, in %1$s) + Plan detail shows a progress bar toward this target. + Enter a valid number + Target must be positive + + + Turn off sells? + You have %1$d open sell order(s). Turning sells off will hide the sell section, but the orders remain on the exchange. You must cancel them manually on the exchange, or by turning sells back on and clicking Cancel. + Turn off From 631869ce99c4badb5017d43363d94648f88bd954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 23:00:59 +0200 Subject: [PATCH 15/75] feat(sell): sell opt-in on AddPlan (Task 21) - Extend CreateDcaPlanUseCase with allowSells + targetProfitAmount params (both default off, opt-in only). - Extend PlanFormDelegate state/setters with sell fields and validation (empty = no target, non-positive number rejected). - PlanFormContent gains a gated "Prodeje (volitelne)" section rendered only when hosting screen passes showSellSection = true. - AddPlanViewModel reads the global tradingEnabled snapshot from UserPreferences and passes it to the form; plan creation scopes allowSells with the global switch (double-gate belt-and-suspenders). Co-Authored-By: Claude Opus 4.6 --- .../domain/usecase/CreateDcaPlanUseCase.kt | 8 ++- .../dca/presentation/plan/PlanFormContent.kt | 63 ++++++++++++++++++- .../dca/presentation/plan/PlanFormDelegate.kt | 32 +++++++++- .../dca/presentation/screens/AddPlanScreen.kt | 5 +- .../presentation/screens/AddPlanViewModel.kt | 24 ++++++- 5 files changed, 122 insertions(+), 10 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt index 998b48e..31668c3 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt @@ -44,7 +44,9 @@ class CreateDcaPlanUseCase @Inject constructor( withdrawalAddress: String? = null, targetAmount: BigDecimal? = null, connectionId: Long? = null, - name: String = "" + name: String = "", + allowSells: Boolean = false, + targetProfitAmount: BigDecimal? = null ) { val now = Instant.now() val nextExecution = if (frequency == DcaFrequency.CUSTOM && cronExpression != null) { @@ -78,7 +80,9 @@ class CreateDcaPlanUseCase @Inject constructor( createdAt = now, nextExecutionAt = nextExecution, targetAmount = targetAmount, - displayOrder = nextDisplayOrder + displayOrder = nextDisplayOrder, + allowSells = allowSells, + targetProfitAmount = targetProfitAmount ) dcaPlanDao.insertPlan(plan) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt index 800c6e6..ef5d799 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt @@ -59,7 +59,13 @@ fun PlanFormContent( exchange: Exchange? = null, showCryptoFiatSelection: Boolean = true, showNameField: Boolean = true, - errorMessage: String? = null + errorMessage: String? = null, + // Sell extension (Task 21/22) - gated by the hosting screen's global trading flag. + // When [showSellSection] is false the section is hidden entirely; when true it + // renders the allow-sells switch and (when allowed) the profit-target field. + showSellSection: Boolean = false, + onAllowSellsChanged: (Boolean) -> Unit = {}, + onTargetProfitAmountChanged: (String) -> Unit = {} ) { Column( modifier = modifier, @@ -241,6 +247,61 @@ fun PlanFormContent( ) } + // Sell extension section (gated by global trading switch in Settings) + if (showSellSection) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + SectionTitle(stringResource(R.string.plan_form_sell_section_title)) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Switch) { onAllowSellsChanged(!state.allowSells) } + .semantics(mergeDescendants = true) { role = Role.Switch }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.plan_form_allow_sells_title), + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + Switch( + checked = state.allowSells, + onCheckedChange = null, + modifier = Modifier.clearAndSetSemantics {} + ) + } + + if (state.allowSells) { + OutlinedTextField( + value = state.targetProfitAmount, + onValueChange = onTargetProfitAmountChanged, + modifier = Modifier.fillMaxWidth(), + label = { + Text(stringResource(R.string.plan_form_target_profit_label, state.selectedFiat)) + }, + isError = state.targetProfitAmountError != null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + supportingText = { + val error = state.targetProfitAmountError + if (error != null) { + // Translate the internal sentinel into the localized resource. + val localized = when (error) { + "Zadej platne cislo" -> stringResource(R.string.plan_form_target_profit_error_invalid) + "Cil musi byt kladny" -> stringResource(R.string.plan_form_target_profit_error_positive) + else -> error + } + Text(localized, color = MaterialTheme.colorScheme.error) + } else { + Text(stringResource(R.string.plan_form_target_profit_supporting)) + } + } + ) + } + } + } + // Error message if (errorMessage != null) { Text( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt index d448e08..66df9e8 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt @@ -35,7 +35,11 @@ data class PlanFormState( val addressError: String? = null, val targetAmount: String = "", val minOrderSize: BigDecimal? = null, - val monthlyCostEstimate: MonthlyCostEstimate? = null + val monthlyCostEstimate: MonthlyCostEstimate? = null, + // Sell-extension (opt-in, gated by global tradingEnabled in the hosting screen) + val allowSells: Boolean = false, + val targetProfitAmount: String = "", + val targetProfitAmountError: String? = null ) { val amountBelowMinimum: Boolean get() { @@ -54,6 +58,7 @@ data class PlanFormState( if (amountBelowMinimum) return false if (withdrawalEnabled && !isAddressValid) return false if (selectedFrequency == DcaFrequency.CUSTOM && !CronUtils.isValidCron(cronExpression)) return false + if (allowSells && targetProfitAmountError != null) return false return true } } @@ -159,6 +164,23 @@ class PlanFormDelegate( _state.update { it.copy(targetAmount = value) } } + fun setAllowSells(value: Boolean) { + _state.update { it.copy(allowSells = value) } + } + + fun setTargetProfitAmount(raw: String) { + val trimmed = raw.trim() + val error = when { + trimmed.isBlank() -> null // empty = no target, valid + trimmed.toBigDecimalOrNull() == null -> "Zadej platne cislo" + trimmed.toBigDecimal() <= BigDecimal.ZERO -> "Cil musi byt kladny" + else -> null + } + _state.update { + it.copy(targetProfitAmount = raw, targetProfitAmountError = error) + } + } + /** Initialize form defaults from an exchange (used when exchange is selected). */ fun initFromExchange(exchange: Exchange) { currentExchange = exchange @@ -186,7 +208,9 @@ class PlanFormDelegate( strategy: DcaStrategy, withdrawalEnabled: Boolean, withdrawalAddress: String, - targetAmount: String + targetAmount: String, + allowSells: Boolean = false, + targetProfitAmount: String = "" ) { currentExchange = exchange val cronDesc = if (cronExpression.isNotBlank()) CronUtils.describeCron(cronExpression) else null @@ -202,7 +226,9 @@ class PlanFormDelegate( selectedStrategy = strategy, withdrawalEnabled = withdrawalEnabled, withdrawalAddress = withdrawalAddress, - targetAmount = targetAmount + targetAmount = targetAmount, + allowSells = allowSells, + targetProfitAmount = targetProfitAmount ) } updateMinOrderSize() diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt index b07cf6b..53ee6f2 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt @@ -320,7 +320,10 @@ fun AddPlanScreen( onWithdrawalAddressChanged = viewModel.planForm::setWithdrawalAddress, onTargetAmountChanged = viewModel.planForm::setTargetAmount, exchange = cred.selectedExchange, - errorMessage = uiState.errorMessage + errorMessage = uiState.errorMessage, + showSellSection = uiState.tradingEnabled, + onAllowSellsChanged = viewModel.planForm::setAllowSells, + onTargetProfitAmountChanged = viewModel.planForm::setTargetProfitAmount ) // Create Button diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt index 70044bb..5d13826 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt @@ -35,6 +35,10 @@ data class AddPlanUiState( // Change tracking val hasChanges: Boolean = false, + // Global trading master switch (from UserPreferences). Gates whether the + // Sells section is shown in the plan form. + val tradingEnabled: Boolean = false, + // Action state val isLoading: Boolean = false, val isSuccess: Boolean = false, @@ -78,7 +82,9 @@ class AddPlanViewModel @Inject constructor( dcaPlanDao = dcaPlanDao ) - private val _localState = MutableStateFlow(AddPlanUiState()) + private val _localState = MutableStateFlow( + AddPlanUiState(tradingEnabled = userPreferences.isTradingEnabled()) + ) val uiState: StateFlow = combine( _localState, @@ -88,7 +94,11 @@ class AddPlanViewModel @Inject constructor( val hasChanges = cred.selectedExchange != null local.copy(planForm = form, credentialForm = cred, hasChanges = hasChanges) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AddPlanUiState()) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + AddPlanUiState(tradingEnabled = userPreferences.isTradingEnabled()) + ) init { credentialForm.initialize() @@ -145,6 +155,12 @@ class AddPlanViewModel @Inject constructor( } } + val tradingGloballyEnabled = userPreferences.isTradingEnabled() + val allowSells = tradingGloballyEnabled && form.allowSells + val targetProfit = if (allowSells) { + form.targetProfitAmount.trim().takeIf { it.isNotEmpty() }?.toBigDecimalOrNull() + } else null + createDcaPlanUseCase.execute( exchange = exchange, connectionId = targetConnectionId, @@ -157,7 +173,9 @@ class AddPlanViewModel @Inject constructor( withdrawalEnabled = form.withdrawalEnabled, withdrawalAddress = if (form.withdrawalEnabled) form.withdrawalAddress.trim() else null, targetAmount = form.targetAmount.toBigDecimalOrNull(), - name = form.name.trim() + name = form.name.trim(), + allowSells = allowSells, + targetProfitAmount = targetProfit ) // Only offer the API import flow when this was a freshly created connection. From d67194a1f4cffdd5e32def944586ec09a16d7e1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 23:03:41 +0200 Subject: [PATCH 16/75] feat(sell): sell section + disable dialog on EditPlan (Task 22) - Inject TransactionDao into EditPlanViewModel and snapshot tradingEnabled at construction. - Load allowSells + targetProfitAmount from the plan entity into the shared PlanFormDelegate. - Save now persists allowSells / targetProfitAmount; the persisted flag is AND-gated with the global trading switch as a safety net. - Toggle-off guard: when user tries to disable allowSells while any open sell orders exist for the plan (observeOpenSellsForPlan count), show a confirmation dialog warning that orders remain on the exchange and must be cancelled manually. - hasChanges now also tracks allowSells + targetProfitAmount. Co-Authored-By: Claude Opus 4.6 --- .../screens/plans/EditPlanScreen.kt | 27 ++++++- .../screens/plans/EditPlanViewModel.kt | 71 +++++++++++++++++-- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanScreen.kt index f150259..a632995 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanScreen.kt @@ -67,6 +67,28 @@ fun EditPlanScreen( ) } + // Confirmation dialog when disabling sells while open sell orders exist on the exchange. + // See EditPlanViewModel.setAllowSells for the trigger. + uiState.showDisableSellsDialog?.let { openCount -> + AlertDialog( + onDismissRequest = { viewModel.dismissDisableSellsDialog() }, + title = { Text(stringResource(R.string.edit_plan_disable_sells_dialog_title)) }, + text = { + Text(stringResource(R.string.edit_plan_disable_sells_dialog_text, openCount)) + }, + confirmButton = { + TextButton(onClick = { viewModel.confirmDisableSells() }) { + Text(stringResource(R.string.edit_plan_disable_sells_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissDisableSellsDialog() }) { + Text(stringResource(R.string.common_cancel)) + } + } + ) + } + Scaffold( topBar = { AccBotTopAppBar( @@ -165,7 +187,10 @@ fun EditPlanScreen( onWithdrawalAddressChanged = viewModel.planForm::setWithdrawalAddress, onTargetAmountChanged = viewModel.planForm::setTargetAmount, exchange = uiState.exchange, - errorMessage = if (uiState.isSaving) uiState.error else null + errorMessage = if (uiState.isSaving) uiState.error else null, + showSellSection = uiState.tradingEnabled, + onAllowSellsChanged = viewModel::setAllowSells, + onTargetProfitAmountChanged = viewModel.planForm::setTargetProfitAmount ) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanViewModel.kt index 3091bd4..175ec0e 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.DcaPlanEntity +import com.accbot.dca.data.local.TransactionDao import com.accbot.dca.domain.model.DcaFrequency import com.accbot.dca.domain.model.DcaStrategy import com.accbot.dca.domain.model.Exchange @@ -38,6 +39,14 @@ data class EditPlanUiState( // Change tracking val hasChanges: Boolean = false, + // Global trading master switch (snapshot at VM construction). Gates whether + // the Sells section is visible in the plan form. + val tradingEnabled: Boolean = false, + + // If non-null, the user tried to turn allowSells off but has that many open + // sell orders - UI should show a confirmation dialog. + val showDisableSellsDialog: Int? = null, + // Action state val isLoading: Boolean = true, val isSaving: Boolean = false, @@ -52,6 +61,7 @@ data class EditPlanUiState( class EditPlanViewModel @Inject constructor( private val application: Application, private val dcaPlanDao: DcaPlanDao, + private val transactionDao: TransactionDao, private val userPreferences: UserPreferences, calculateMonthlyCost: CalculateMonthlyCostUseCase, minOrderSizeRepository: MinOrderSizeRepository @@ -59,7 +69,9 @@ class EditPlanViewModel @Inject constructor( val planForm = PlanFormDelegate(calculateMonthlyCost, minOrderSizeRepository, viewModelScope) - private val _localState = MutableStateFlow(EditPlanUiState()) + private val _localState = MutableStateFlow( + EditPlanUiState(tradingEnabled = userPreferences.isTradingEnabled()) + ) private val _originalFormState = MutableStateFlow(null) @@ -76,13 +88,54 @@ class EditPlanViewModel @Inject constructor( || form.withdrawalEnabled != original.withdrawalEnabled || form.withdrawalAddress != original.withdrawalAddress || form.targetAmount != original.targetAmount + || form.allowSells != original.allowSells + || form.targetProfitAmount != original.targetProfitAmount ) local.copy(planForm = form, hasChanges = hasChanges) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), EditPlanUiState()) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + EditPlanUiState(tradingEnabled = userPreferences.isTradingEnabled()) + ) private var originalPlan: DcaPlanEntity? = null + /** + * Intercept the allow-sells toggle: when the user tries to turn it OFF and there + * are open sell orders for this plan, show a confirmation dialog first. Otherwise + * apply the change immediately via the form delegate. + */ + fun setAllowSells(value: Boolean) { + val currentForm = planForm.state.value + if (!value && currentForm.allowSells) { + val planId = _localState.value.planId + if (planId <= 0L) { + planForm.setAllowSells(false) + return + } + viewModelScope.launch { + val openCount = transactionDao.observeOpenSellsForPlan(planId).first().size + if (openCount > 0) { + _localState.update { it.copy(showDisableSellsDialog = openCount) } + } else { + planForm.setAllowSells(false) + } + } + } else { + planForm.setAllowSells(value) + } + } + + fun confirmDisableSells() { + planForm.setAllowSells(false) + _localState.update { it.copy(showDisableSellsDialog = null) } + } + + fun dismissDisableSellsDialog() { + _localState.update { it.copy(showDisableSellsDialog = null) } + } + fun loadPlan(planId: Long) { viewModelScope.launch { _localState.update { it.copy(isLoading = true, error = null) } @@ -117,7 +170,9 @@ class EditPlanViewModel @Inject constructor( strategy = plan.strategy, withdrawalEnabled = plan.withdrawalEnabled, withdrawalAddress = plan.withdrawalAddress ?: "", - targetAmount = plan.targetAmount?.toPlainString() ?: "" + targetAmount = plan.targetAmount?.toPlainString() ?: "", + allowSells = plan.allowSells, + targetProfitAmount = plan.targetProfitAmount?.toPlainString() ?: "" ) // Snapshot the original form state for change tracking @@ -179,6 +234,12 @@ class EditPlanViewModel @Inject constructor( plan.nextExecutionAt } + val tradingGloballyEnabled = userPreferences.isTradingEnabled() + val allowSells = tradingGloballyEnabled && form.allowSells + val targetProfit = if (allowSells) { + form.targetProfitAmount.trim().takeIf { it.isNotEmpty() }?.toBigDecimalOrNull() + } else null + val updatedPlan = plan.copy( amount = amount, frequency = form.selectedFrequency, @@ -187,7 +248,9 @@ class EditPlanViewModel @Inject constructor( withdrawalEnabled = form.withdrawalEnabled, withdrawalAddress = if (form.withdrawalEnabled) form.withdrawalAddress.trim() else null, nextExecutionAt = nextExecution, - targetAmount = form.targetAmount.toBigDecimalOrNull() + targetAmount = form.targetAmount.toBigDecimalOrNull(), + allowSells = allowSells, + targetProfitAmount = targetProfit ) dcaPlanDao.updatePlan(updatedPlan) From f37aa52bc058ac483d8cc8e655a8257c6452f67a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 23:35:30 +0200 Subject: [PATCH 17/75] feat(sell): plan-detail P&L card + open sells (Task 23) Extend PlanDetailsViewModel with planPnL/openSells/sellUiVisible flows and a cancelSell action backed by CancelSellOrderUseCase. PnL is recomputed on each transaction change and spot-price refresh; open sells come from the DAO flow observeOpenSellsForPlan. sellUiVisible is gated by plan.allowSells, the global trading switch and the exchange's supportsLimitSell capability. Two new composables under plans/components/: - PnLCard: held / avg buy / realized / unrealized / net rows with green/red coloring, plus optional target-profit progress bar. - OpenSellsList: per-row pending/partial sell with confirm-dialog cancel. Wired into PlanDetailsScreen between the exchange-balance card and the transaction list, plus a "+ Vytvorit prodejni prikaz" button that launches the wizard (implemented in Tasks 24-25). --- .../screens/plans/PlanDetailsScreen.kt | 59 +++++++ .../screens/plans/PlanDetailsViewModel.kt | 74 ++++++++- .../screens/plans/components/OpenSellsList.kt | 151 ++++++++++++++++++ .../screens/plans/components/PnLCard.kt | 147 +++++++++++++++++ .../app/src/main/res/values-cs/strings.xml | 1 + .../app/src/main/res/values/strings.xml | 1 + 6 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/OpenSellsList.kt create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/PnLCard.kt diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt index 1841d36..ef2ca98 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt @@ -30,6 +30,9 @@ import com.accbot.dca.domain.model.DcaStrategy import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.supportsApiImport import com.accbot.dca.presentation.components.* +import com.accbot.dca.presentation.screens.plans.components.OpenSellsList +import com.accbot.dca.presentation.screens.plans.components.PnLCard +import com.accbot.dca.presentation.screens.plans.sell.SellWizardBottomSheet import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardType @@ -50,6 +53,9 @@ fun PlanDetailsScreen( viewModel: PlanDetailsViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val sellUiVisible by viewModel.sellUiVisible.collectAsStateWithLifecycle() + val planPnL by viewModel.planPnL.collectAsStateWithLifecycle() + val openSells by viewModel.openSells.collectAsStateWithLifecycle() val context = LocalContext.current val coroutineScope = rememberCoroutineScope() var showDeleteDialog by rememberSaveable { mutableStateOf(false) } @@ -59,12 +65,24 @@ fun PlanDetailsScreen( var showDeleteTransactionsDialog by rememberSaveable { mutableStateOf(false) } var deleteTransactionsConfirmText by rememberSaveable { mutableStateOf("") } var dangerZoneExpanded by rememberSaveable { mutableStateOf(false) } + var sellWizardOpen by rememberSaveable { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(planId) { viewModel.loadPlan(planId) } + LaunchedEffect(Unit) { + viewModel.snackbar.collect { msg -> snackbarHostState.showSnackbar(msg) } + } + + if (sellWizardOpen) { + SellWizardBottomSheet( + planId = planId, + onDismiss = { sellWizardOpen = false } + ) + } + // Delete confirmation dialog if (showDeleteDialog) { val plan = uiState.plan @@ -713,6 +731,47 @@ fun PlanDetailsScreen( } } + // 4.5 Sell section (P&L card, open orders, create-sell button). + // Shown only when plan opted in + global trading enabled + exchange supports it. + if (sellUiVisible) { + planPnL?.let { pnl -> + item { + PnLCard( + pnl = pnl, + fiat = plan.fiat, + crypto = plan.crypto, + targetAmount = plan.targetProfitAmount + ) + } + } + + if (openSells.isNotEmpty()) { + item { + OpenSellsList( + openSells = openSells, + onCancelClick = viewModel::cancelSell + ) + } + } + + item { + val heldCrypto = planPnL?.currentCryptoHeld ?: BigDecimal.ZERO + Button( + onClick = { sellWizardOpen = true }, + enabled = heldCrypto > BigDecimal.ZERO, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.Sell, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.plan_details_create_sell_order)) + } + } + } + // 5. Transactions section (with Import API in header) item { Row( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt index 0f252c4..cbe0ea5 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt @@ -8,11 +8,14 @@ import androidx.room.withTransaction import com.accbot.dca.data.local.* import com.accbot.dca.data.remote.MarketDataService import com.accbot.dca.domain.model.DcaPlan +import com.accbot.dca.domain.model.PlanPnL import com.accbot.dca.domain.model.Transaction import com.accbot.dca.scheduler.DcaAlarmScheduler import com.accbot.dca.domain.model.TransactionStatus import com.accbot.dca.domain.usecase.ApiImportProgress import com.accbot.dca.domain.usecase.ApiImportResultState +import com.accbot.dca.domain.usecase.CalculatePlanPnLUseCase +import com.accbot.dca.domain.usecase.CancelSellOrderUseCase import com.accbot.dca.domain.usecase.ImportTradeHistoryUseCase import com.accbot.dca.exchange.ExchangeApiFactory import com.accbot.dca.presentation.utils.NumberFormatters @@ -70,20 +73,43 @@ class PlanDetailsViewModel @Inject constructor( private val exchangeApiFactory: ExchangeApiFactory, private val credentialsStore: CredentialsStore, private val userPreferences: UserPreferences, - private val importTradeHistoryUseCase: ImportTradeHistoryUseCase + private val importTradeHistoryUseCase: ImportTradeHistoryUseCase, + private val calculatePlanPnLUseCase: CalculatePlanPnLUseCase, + private val cancelSellOrderUseCase: CancelSellOrderUseCase ) : ViewModel() { private val _uiState = MutableStateFlow(PlanDetailsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _planPnL = MutableStateFlow(null) + val planPnL: StateFlow = _planPnL.asStateFlow() + + private val _openSells = MutableStateFlow>(emptyList()) + val openSells: StateFlow> = _openSells.asStateFlow() + + private val _sellUiVisible = MutableStateFlow(false) + val sellUiVisible: StateFlow = _sellUiVisible.asStateFlow() + + private val _snackbar = MutableSharedFlow(extraBufferCapacity = 4) + val snackbar: SharedFlow = _snackbar.asSharedFlow() + private var planId: Long = 0 private var transactionCollectionJob: Job? = null + private var openSellsJob: Job? = null private var priceJob: Job? = null private var balanceJob: Job? = null fun loadPlan(planId: Long) { this.planId = planId transactionCollectionJob?.cancel() + openSellsJob?.cancel() + + // Observe open sells for the plan independently of the main transactions flow. + openSellsJob = viewModelScope.launch { + transactionDao.observeOpenSellsForPlan(planId).collect { entities -> + _openSells.value = entities.map { it.toDomain() } + } + } transactionCollectionJob = viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } @@ -98,6 +124,9 @@ class PlanDetailsViewModel @Inject constructor( val plan = planEntity.toDomain() + // Compute sell UI visibility: plan opt-in + master switch + exchange capability. + _sellUiVisible.value = computeSellUiVisible(plan) + // Check how many OTHER plans share the same connection (for import warning) val totalPlansOnConnection = dcaPlanDao.countPlansByConnection(planEntity.connectionId) val otherPlans = (totalPlansOnConnection - 1).coerceAtLeast(0) @@ -142,6 +171,9 @@ class PlanDetailsViewModel @Inject constructor( priceJob = fetchCurrentPrice(plan, totalCrypto, totalInvested) balanceJob?.cancel() balanceJob = fetchFiatBalance(plan) + + // Recompute PnL whenever transactions change (uses last known spot price). + recomputePnL(planId, _uiState.value.currentPrice) } } catch (e: Exception) { _uiState.update { @@ -176,6 +208,8 @@ class PlanDetailsViewModel @Inject constructor( isPriceLoading = false ) } } + // Refresh PnL with the (possibly new) spot price. + recomputePnL(plan.id, price) } catch (e: Exception) { Log.w(TAG, "Failed to fetch price: ${e.message}") _uiState.update { it.copy(isPriceLoading = false) } @@ -183,6 +217,44 @@ class PlanDetailsViewModel @Inject constructor( } } + private suspend fun recomputePnL(planId: Long, spot: BigDecimal?) { + try { + _planPnL.value = calculatePlanPnLUseCase(planId, spot) + } catch (e: Exception) { + Log.w(TAG, "Failed to compute PnL: ${e.message}") + } + } + + /** + * Sell UI is shown only when plan opted in, global trading is enabled, and the + * exchange implementation actually supports limit sells. + */ + private suspend fun computeSellUiVisible(plan: DcaPlan): Boolean { + if (!plan.allowSells) return false + if (!userPreferences.isTradingEnabled()) return false + return try { + val credentials = credentialsStore.getCredentials( + plan.connectionId, + userPreferences.isSandboxMode() + ) ?: return false + exchangeApiFactory.create(credentials).supportsLimitSell + } catch (e: Exception) { + Log.w(TAG, "Failed to check limit-sell support: ${e.message}") + false + } + } + + fun cancelSell(txId: Long) { + viewModelScope.launch { + val result = cancelSellOrderUseCase(txId) + if (result.isFailure) { + _snackbar.emit( + "Zruseni orderu selhalo: ${result.exceptionOrNull()?.message ?: "neznama chyba"}" + ) + } + } + } + private fun fetchFiatBalance(plan: DcaPlan): Job { return viewModelScope.launch { _uiState.update { it.copy(isBalanceLoading = true) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/OpenSellsList.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/OpenSellsList.kt new file mode 100644 index 0000000..31c0a65 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/OpenSellsList.kt @@ -0,0 +1,151 @@ +package com.accbot.dca.presentation.screens.plans.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.accbot.dca.domain.model.Transaction +import com.accbot.dca.domain.model.TransactionStatus +import com.accbot.dca.presentation.ui.theme.Error +import com.accbot.dca.presentation.utils.NumberFormatters +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Card listing all open (PENDING / PARTIAL) sell orders for a plan with per-row + * cancel action. Hidden entirely when there are no open sells. + */ +@Composable +fun OpenSellsList( + openSells: List, + onCancelClick: (Long) -> Unit, + modifier: Modifier = Modifier +) { + if (openSells.isEmpty()) return + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Otevrene sell ordery (${openSells.size})", + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.semantics { heading() } + ) + Spacer(Modifier.height(8.dp)) + openSells.forEach { tx -> + OpenSellRow(tx = tx, onCancelClick = onCancelClick) + } + } + } +} + +@Composable +private fun OpenSellRow( + tx: Transaction, + onCancelClick: (Long) -> Unit +) { + val requested = tx.requestedCryptoAmount ?: BigDecimal.ZERO + val filled = tx.cryptoAmount + val progressPct = if (requested > BigDecimal.ZERO) { + filled.divide(requested, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + .toInt() + } else 0 + + var showConfirm by remember(tx.id) { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + val priceText = tx.limitPrice?.let { NumberFormatters.fiat(it) } ?: "-" + Text( + text = "${NumberFormatters.crypto(requested)} ${tx.crypto} @ $priceText ${tx.fiat}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + if (tx.status == TransactionStatus.PARTIAL) { + Text( + text = "Castecne: $progressPct% (${NumberFormatters.crypto(filled)} / ${NumberFormatters.crypto(requested)})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary + ) + } else { + Text( + text = "Ceka na vyplneni", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + IconButton(onClick = { showConfirm = true }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Zrusit order", + tint = Error + ) + } + } + + if (showConfirm) { + val priceText = tx.limitPrice?.let { NumberFormatters.fiat(it) } ?: "-" + AlertDialog( + onDismissRequest = { showConfirm = false }, + title = { Text("Zrusit order?") }, + text = { + Text( + "Opravdu zrusit limitni prodej ${NumberFormatters.crypto(requested)} ${tx.crypto} @ $priceText ${tx.fiat}?" + ) + }, + confirmButton = { + TextButton(onClick = { + showConfirm = false + onCancelClick(tx.id) + }) { + Text("Zrusit order", color = Error) + } + }, + dismissButton = { + TextButton(onClick = { showConfirm = false }) { + Text("Zpet") + } + } + ) + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/PnLCard.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/PnLCard.kt new file mode 100644 index 0000000..1ab24b1 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/PnLCard.kt @@ -0,0 +1,147 @@ +package com.accbot.dca.presentation.screens.plans.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.accbot.dca.domain.model.PlanPnL +import com.accbot.dca.presentation.ui.theme.Error +import com.accbot.dca.presentation.ui.theme.successColor +import com.accbot.dca.presentation.utils.NumberFormatters +import java.math.BigDecimal + +/** + * Plan-level profit & loss card with realized / unrealized / net breakdown and + * optional target-progress bar when the plan has `targetProfitAmount` configured. + * + * Null-valued PnL fields render as "-" (no spot price available / no buys yet). + */ +@Composable +fun PnLCard( + pnl: PlanPnL, + fiat: String, + crypto: String, + targetAmount: BigDecimal?, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "P&L", + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.semantics { heading() } + ) + Spacer(Modifier.height(12.dp)) + + val heldValue = if (pnl.currentValueFiat != null) { + "${NumberFormatters.crypto(pnl.currentCryptoHeld)} $crypto (${NumberFormatters.fiat(pnl.currentValueFiat)} $fiat)" + } else { + "${NumberFormatters.crypto(pnl.currentCryptoHeld)} $crypto" + } + PnLRow(label = "Drzeno:", value = heldValue) + + PnLRow( + label = "Prum. nakup:", + value = pnl.avgBuyPrice?.let { "${NumberFormatters.fiat(it)} $fiat" } ?: "-" + ) + + PnLRow( + label = "Realizovany:", + value = formatOptionalPnL(pnl.realizedPnL, fiat), + color = colorForPnL(pnl.realizedPnL) + ) + PnLRow( + label = "Nerealizovany:", + value = formatOptionalPnL(pnl.unrealizedPnL, fiat), + color = colorForPnL(pnl.unrealizedPnL) + ) + PnLRow( + label = "Net:", + value = formatOptionalPnL(pnl.netPnL, fiat), + color = colorForPnL(pnl.netPnL), + bold = true + ) + + if (targetAmount != null && pnl.targetProgressPct != null) { + Spacer(Modifier.height(12.dp)) + LinearProgressIndicator( + progress = { pnl.targetProgressPct.toFloat().coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "Cil: ${NumberFormatters.fiat(targetAmount)} $fiat (${(pnl.targetProgressPct * 100).toInt()}%)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun PnLRow( + label: String, + value: String, + color: Color? = null, + bold: Boolean = false +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 3.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = color ?: LocalContentColor.current, + fontWeight = if (bold) FontWeight.Bold else FontWeight.Medium + ) + } +} + +@Composable +private fun colorForPnL(value: BigDecimal?): Color? = when { + value == null -> null + value > BigDecimal.ZERO -> successColor() + value < BigDecimal.ZERO -> Error + else -> null +} + +private fun formatOptionalPnL(value: BigDecimal?, fiat: String): String { + if (value == null) return "-" + val prefix = if (value >= BigDecimal.ZERO) "+" else "" + return "$prefix${NumberFormatters.fiat(value)} $fiat" +} diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 9ad50cb..c0c3829 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -313,6 +313,7 @@ Zadejte %1$d pro potvrzení Smazat všechny transakce %1$d transakcí smazáno + + Vytvořit prodejní příkaz Upravit plán diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 2137057..9042789 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -312,6 +312,7 @@ Type %1$d to confirm Delete All Transactions %1$d transactions deleted + + Vytvorit prodejni prikaz Edit Plan From 537888503510e3a9ef0e00e8dbc5e4a078893f37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 23:37:38 +0200 Subject: [PATCH 18/75] feat(sell): sell wizard step 1 (Task 24) New SellWizardViewModel backing a two-step limit-sell flow. init() loads the plan, computes available crypto (held minus reservations from other open sells), fetches a best-effort spot price and derives the avg buy price via CalculatePlanPnLUseCase. Inputs trigger live revalidation through ValidateSellOrderUseCase; minOrderSize is set per-exchange (Binance LOT_SIZE, default 0.00001 otherwise). SellWizardBottomSheet is a ModalBottomSheet with the INPUT step wired up: - 25/50/75/100% amount chips - Trzni / Breakeven / +10% / +25% price chips (disabled when inputs missing) - live Ziskate + Zisk-vs-prum summary with green/red coloring - validation banners (HardError / InstantFillInfo / FarFromMarketWarning) - Pokracovat button enabled only when no hard errors and both fields parse The CONFIRM step currently falls through to INPUT; Task 25 adds the real confirm + submit UI. --- .../plans/sell/SellWizardBottomSheet.kt | 356 ++++++++++++++++++ .../screens/plans/sell/SellWizardViewModel.kt | 272 +++++++++++++ 2 files changed, 628 insertions(+) create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt new file mode 100644 index 0000000..ef97faa --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -0,0 +1,356 @@ +package com.accbot.dca.presentation.screens.plans.sell + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.accbot.dca.domain.usecase.SellValidation +import com.accbot.dca.presentation.ui.theme.Error +import com.accbot.dca.presentation.ui.theme.successColor +import com.accbot.dca.presentation.utils.NumberFormatters +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Two-step bottom sheet for placing a limit sell order: + * 1. INPUT - amount + price with quick-set chips, live validations and summary + * 2. CONFIRM - read-only summary + warning + submit (added in Task 25) + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SellWizardBottomSheet( + planId: Long, + onDismiss: () -> Unit, + viewModel: SellWizardViewModel = hiltViewModel() +) { + LaunchedEffect(planId) { viewModel.init(planId) } + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(state.dismissRequested) { + if (state.dismissRequested) { + viewModel.consumeDismiss() + onDismiss() + } + } + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = Modifier.fillMaxHeight(0.95f) + ) { + when (state.step) { + SellWizardViewModel.Step.INPUT -> SellInputStep(state, viewModel, onDismiss) + SellWizardViewModel.Step.CONFIRM -> { + // Filled in by Task 25; for now fall back to input so the flow is safe. + SellInputStep(state, viewModel, onDismiss) + } + } + } +} + +@Composable +private fun SellInputStep( + state: SellWizardViewModel.UiState, + vm: SellWizardViewModel, + onDismiss: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + ) { + // Header + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Zavrit") + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Limit sell ${state.crypto}/${state.fiat}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = state.exchangeName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(Modifier.height(12.dp)) + + // Info block + InfoRow( + "Aktualni cena:", + state.spotPrice?.let { "${NumberFormatters.fiat(it)} ${state.fiat}" } ?: "-" + ) + InfoRow( + "Prum. nakup:", + state.avgBuyPrice?.let { "${NumberFormatters.fiat(it)} ${state.fiat}" } ?: "-" + ) + InfoRow( + "K dispozici:", + "${NumberFormatters.crypto(state.availableToSell)} ${state.crypto}" + ) + + Spacer(Modifier.height(16.dp)) + Text( + "Mnozstvi", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(4.dp)) + OutlinedTextField( + value = state.amountInput, + onValueChange = vm::setAmount, + trailingIcon = { + Text( + text = state.crypto, + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Row( + modifier = Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + listOf(25 to "25%", 50 to "50%", 75 to "75%", 100 to "Vse").forEach { (pct, label) -> + AssistChip( + onClick = { vm.setAmountPct(pct) }, + label = { Text(label) } + ) + } + } + + Spacer(Modifier.height(16.dp)) + Text( + "Limitni cena", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(4.dp)) + OutlinedTextField( + value = state.priceInput, + onValueChange = vm::setPrice, + trailingIcon = { + Text( + text = state.fiat, + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Row( + modifier = Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + AssistChip( + onClick = vm::setPriceSpot, + label = { Text("Trzni") }, + enabled = state.spotPrice != null + ) + AssistChip( + onClick = vm::setPriceBreakeven, + label = { Text("Breakeven") }, + enabled = state.avgBuyPrice != null + ) + AssistChip( + onClick = { vm.setPriceAvgPlus(10) }, + label = { Text("+10%") }, + enabled = state.avgBuyPrice != null + ) + AssistChip( + onClick = { vm.setPriceAvgPlus(25) }, + label = { Text("+25%") }, + enabled = state.avgBuyPrice != null + ) + } + + Spacer(Modifier.height(16.dp)) + Text( + "Souhrn", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(4.dp)) + val amountBD = state.amountInput.toBigDecimalOrNull() ?: BigDecimal.ZERO + val priceBD = state.priceInput.toBigDecimalOrNull() ?: BigDecimal.ZERO + val proceeds = (amountBD * priceBD).setScale(2, RoundingMode.HALF_UP) + InfoRow("Ziskate:", "${NumberFormatters.fiat(proceeds)} ${state.fiat}") + state.avgBuyPrice?.let { avg -> + val profit = ((priceBD - avg) * amountBD).setScale(2, RoundingMode.HALF_UP) + val profitPct = if (avg > BigDecimal.ZERO) { + (priceBD - avg).divide(avg, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + .setScale(1, RoundingMode.HALF_UP) + } else BigDecimal.ZERO + val sign = if (profit >= BigDecimal.ZERO) "+" else "" + InfoRow( + label = "Zisk vs prum:", + value = "$sign${NumberFormatters.fiat(profit)} ${state.fiat} ($sign${profitPct.toPlainString()}%)", + color = when { + profit > BigDecimal.ZERO -> successColor() + profit < BigDecimal.ZERO -> Error + else -> null + } + ) + } + + // Validations + Spacer(Modifier.height(8.dp)) + state.validations.forEach { v -> + when (v) { + is SellValidation.HardError -> Text( + text = v.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(vertical = 4.dp) + ) + is SellValidation.InstantFillInfo -> InfoBanner( + "Prodej probehne okamzite. Limitni cena je pod aktualni trzni (${NumberFormatters.fiat(v.spot)} ${state.fiat}). Prikaz se zfilluje ihned za nejvyssi nabidku na burze (obvykle blizko trzni ceny minus spread). Neni to chyba." + ) + is SellValidation.FarFromMarketWarning -> WarningBanner( + "Cena je vysoko nad trhem - prodej se nemusi zfillovat dlouho." + ) + is SellValidation.Ok -> { /* no-op */ } + } + } + + Spacer(Modifier.height(16.dp)) + Button( + onClick = vm::proceedToConfirm, + enabled = state.canProceed, + modifier = Modifier.fillMaxWidth() + ) { + Text("Pokracovat") + } + + Spacer(Modifier.height(32.dp)) + } +} + +@Composable +internal fun InfoRow( + label: String, + value: String, + color: Color? = null +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = color ?: LocalContentColor.current, + fontWeight = FontWeight.Medium + ) + } +} + +@Composable +internal fun InfoBanner(text: String) { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.Top) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } +} + +@Composable +internal fun WarningBanner(text: String) { + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.Top) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt new file mode 100644 index 0000000..8143c1f --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt @@ -0,0 +1,272 @@ +package com.accbot.dca.presentation.screens.plans.sell + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.TransactionSide +import com.accbot.dca.domain.model.TransactionStatus +import com.accbot.dca.domain.usecase.CalculatePlanPnLUseCase +import com.accbot.dca.domain.usecase.PlaceLimitSellUseCase +import com.accbot.dca.domain.usecase.SellValidation +import com.accbot.dca.domain.usecase.ValidateSellOrderUseCase +import com.accbot.dca.exchange.ExchangeApiFactory +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import java.math.BigDecimal +import java.math.RoundingMode +import javax.inject.Inject + +/** + * State + actions for the two-step limit-sell wizard (input -> confirm -> submit). + * + * Lifecycle: the hosting bottom-sheet calls [init] once per open, then [setAmount] / + * [setPrice] / [proceedToConfirm] / [submit] based on user actions. On successful submit + * [UiState.dismissRequested] flips true; the sheet consumes via [consumeDismiss] and + * closes. On timeout [UiState.showTimeoutDialog] is set so the user is warned to check + * the exchange manually for duplicate orders. + */ +@HiltViewModel +class SellWizardViewModel @Inject constructor( + private val validateSellOrderUseCase: ValidateSellOrderUseCase, + private val placeLimitSellUseCase: PlaceLimitSellUseCase, + private val calculatePlanPnLUseCase: CalculatePlanPnLUseCase, + private val database: DcaDatabase, + private val credentialsStore: CredentialsStore, + private val exchangeApiFactory: ExchangeApiFactory, + private val userPreferences: UserPreferences +) : ViewModel() { + + enum class Step { INPUT, CONFIRM } + + data class UiState( + val planId: Long = 0, + val planName: String = "", + val exchangeName: String = "", + val crypto: String = "", + val fiat: String = "", + val held: BigDecimal = BigDecimal.ZERO, + /** held crypto minus crypto already reserved by other open sells (unfilled). */ + val availableToSell: BigDecimal = BigDecimal.ZERO, + val spotPrice: BigDecimal? = null, + val avgBuyPrice: BigDecimal? = null, + val minOrderSize: BigDecimal = BigDecimal("0.00001"), + val amountInput: String = "", + val priceInput: String = "", + val validations: List = emptyList(), + val step: Step = Step.INPUT, + val initializing: Boolean = true, + val submitting: Boolean = false, + val submitError: String? = null, + val showTimeoutDialog: Boolean = false, + val dismissRequested: Boolean = false + ) { + /** Proceed button enabled when no hard errors + both numeric inputs parse. */ + val canProceed: Boolean + get() = validations.none { it is SellValidation.HardError } && + amountInput.isNotBlank() && priceInput.isNotBlank() && + amountInput.toBigDecimalOrNull() != null && + priceInput.toBigDecimalOrNull() != null + } + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var initialized = false + + /** + * Load plan state, compute held crypto, available-to-sell (minus reservations from + * open sells), avg buy price and a best-effort spot price. Idempotent across + * recompositions thanks to [initialized]. + */ + fun init(planId: Long) { + if (initialized) return + initialized = true + viewModelScope.launch { + val plan = database.dcaPlanDao().getPlanById(planId) + if (plan == null) { + _uiState.update { it.copy(initializing = false) } + return@launch + } + + // Best-effort spot price via exchange API; null means UI shows "-". + val credentials = try { + credentialsStore.getCredentials(plan.connectionId, userPreferences.isSandboxMode()) + } catch (e: Exception) { + Log.w(TAG, "getCredentials failed: ${e.message}") + null + } + val api = credentials?.let { exchangeApiFactory.create(it) } + val spot = api?.let { + try { + withTimeoutOrNull(10_000) { it.getCurrentPrice(plan.crypto, plan.fiat) } + } catch (e: Exception) { + Log.w(TAG, "getCurrentPrice failed: ${e.message}") + null + } + } + + val pnl = try { + calculatePlanPnLUseCase(planId, spot) + } catch (e: Exception) { + Log.w(TAG, "PnL calc failed: ${e.message}") + null + } + val held = pnl?.currentCryptoHeld ?: BigDecimal.ZERO + val avgBuy = pnl?.avgBuyPrice + + // Crypto reserved by other open sells (requested - filled) - prevents the + // user from submitting a sell that, together with existing open sells, + // exceeds what they actually hold. + val openSells = database.transactionDao().getTransactionsByPlanSync(planId) + .filter { + it.side == TransactionSide.SELL && + it.status in setOf(TransactionStatus.PENDING, TransactionStatus.PARTIAL) + } + val reserved = openSells.fold(BigDecimal.ZERO) { acc, tx -> + acc + ((tx.requestedCryptoAmount ?: BigDecimal.ZERO) - tx.cryptoAmount) + } + + val minOrder = when (plan.exchange) { + Exchange.BINANCE -> + Exchange.binanceLotStepSize[plan.crypto]?.let(::BigDecimal) ?: BigDecimal("0.00001") + else -> BigDecimal("0.00001") + } + + _uiState.update { + it.copy( + planId = planId, + planName = plan.name.ifBlank { "${plan.crypto}/${plan.fiat}" }, + exchangeName = plan.exchange.displayName, + crypto = plan.crypto, + fiat = plan.fiat, + held = held, + availableToSell = (held - reserved).max(BigDecimal.ZERO), + spotPrice = spot, + avgBuyPrice = avgBuy, + minOrderSize = minOrder, + initializing = false + ) + } + revalidate() + } + } + + fun setAmount(value: String) { + _uiState.update { it.copy(amountInput = value) } + revalidate() + } + + fun setAmountPct(pct: Int) { + val target = _uiState.value.availableToSell + .multiply(BigDecimal(pct)) + .divide(BigDecimal(100), 8, RoundingMode.HALF_UP) + setAmount(target.stripTrailingZeros().toPlainString()) + } + + fun setPrice(value: String) { + _uiState.update { it.copy(priceInput = value) } + revalidate() + } + + fun setPriceSpot() { + _uiState.value.spotPrice?.let { + setPrice(it.stripTrailingZeros().toPlainString()) + } + } + + fun setPriceBreakeven() { + _uiState.value.avgBuyPrice?.let { + setPrice(it.setScale(2, RoundingMode.HALF_UP).toPlainString()) + } + } + + fun setPriceAvgPlus(pct: Int) { + _uiState.value.avgBuyPrice?.let { avg -> + val multiplier = BigDecimal.ONE + + BigDecimal(pct).divide(BigDecimal(100), 4, RoundingMode.HALF_UP) + setPrice((avg * multiplier).setScale(2, RoundingMode.HALF_UP).toPlainString()) + } + } + + fun proceedToConfirm() { + _uiState.update { it.copy(step = Step.CONFIRM, submitError = null) } + } + + fun back() { + _uiState.update { it.copy(step = Step.INPUT, submitError = null) } + } + + /** + * Place the limit sell order. On timeout we show a warning dialog (order may be + * placed but we couldn't confirm). On success the sheet is asked to dismiss. + */ + fun submit() { + val state = _uiState.value + val amount = state.amountInput.toBigDecimalOrNull() ?: return + val price = state.priceInput.toBigDecimalOrNull() ?: return + + viewModelScope.launch { + _uiState.update { it.copy(submitting = true, submitError = null) } + + val result = withTimeoutOrNull(15_000L) { + placeLimitSellUseCase(state.planId, amount, price) + } + + when { + result == null -> + _uiState.update { it.copy(submitting = false, showTimeoutDialog = true) } + result.isSuccess -> + _uiState.update { it.copy(submitting = false, dismissRequested = true) } + else -> + _uiState.update { + it.copy( + submitting = false, + submitError = result.exceptionOrNull()?.message ?: "Neznama chyba" + ) + } + } + } + } + + fun dismissTimeoutDialog() { + _uiState.update { it.copy(showTimeoutDialog = false) } + } + + fun consumeDismiss() { + _uiState.update { it.copy(dismissRequested = false) } + } + + private fun revalidate() { + val state = _uiState.value + val amount = state.amountInput.toBigDecimalOrNull() + val price = state.priceInput.toBigDecimalOrNull() + if (amount == null || price == null) { + _uiState.update { it.copy(validations = emptyList()) } + return + } + viewModelScope.launch { + val validations = try { + validateSellOrderUseCase( + state.planId, amount, price, state.minOrderSize, state.spotPrice + ) + } catch (e: Exception) { + Log.w(TAG, "validate failed: ${e.message}") + emptyList() + } + _uiState.update { it.copy(validations = validations) } + } + } + + companion object { + private const val TAG = "SellWizardViewModel" + } +} From a23a20773021eeae28d6a2cfdf5db74626bde9bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 23:39:10 +0200 Subject: [PATCH 19/75] feat(sell): sell wizard step 2 + submit (Task 25) Replace the placeholder confirm branch with SellConfirmStep: a read-only summary (burza / plan / smer / mnozstvi / limitni cena / ziskate) plus a warning banner, Zpet / Odeslat buttons and a loading indicator while PlaceLimitSellUseCase runs. submit() applies the 15s timeout in SellWizardViewModel; on timeout we show an AlertDialog telling the user to verify on the exchange to avoid duplicate orders. --- .../plans/sell/SellWizardBottomSheet.kt | 128 +++++++++++++++++- 1 file changed, 124 insertions(+), 4 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index ef97faa..43e3ac1 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -15,17 +15,21 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -79,10 +83,7 @@ fun SellWizardBottomSheet( ) { when (state.step) { SellWizardViewModel.Step.INPUT -> SellInputStep(state, viewModel, onDismiss) - SellWizardViewModel.Step.CONFIRM -> { - // Filled in by Task 25; for now fall back to input so the flow is safe. - SellInputStep(state, viewModel, onDismiss) - } + SellWizardViewModel.Step.CONFIRM -> SellConfirmStep(state, viewModel) } } } @@ -277,6 +278,125 @@ private fun SellInputStep( } } +@Composable +private fun SellConfirmStep( + state: SellWizardViewModel.UiState, + vm: SellWizardViewModel +) { + val amountBD = state.amountInput.toBigDecimalOrNull() ?: BigDecimal.ZERO + val priceBD = state.priceInput.toBigDecimalOrNull() ?: BigDecimal.ZERO + val proceeds = (amountBD * priceBD).setScale(2, RoundingMode.HALF_UP) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = vm::back) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zpet") + } + Text( + "Potvrdit prodej", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + + Spacer(Modifier.height(12.dp)) + + SummaryRow("Burza:", state.exchangeName) + SummaryRow("Plan:", state.planName) + SummaryRow("Smer:", "PRODEJ") + SummaryRow("Mnozstvi:", "${NumberFormatters.crypto(amountBD)} ${state.crypto}") + SummaryRow("Limitni cena:", "${NumberFormatters.fiat(priceBD)} ${state.fiat}") + SummaryRow("Ziskate:", "${NumberFormatters.fiat(proceeds)} ${state.fiat}") + + Spacer(Modifier.height(16.dp)) + WarningBanner( + "Tato akce odesle prikaz na ${state.exchangeName} a nelze ji vratit. Prikaz lze pote zrusit, dokud neni castecne nebo cele zfillovan." + ) + + state.submitError?.let { err -> + Spacer(Modifier.height(8.dp)) + Text( + text = err, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = vm::back, + enabled = !state.submitting, + modifier = Modifier.weight(1f) + ) { + Text("Zpet") + } + Button( + onClick = { vm.submit() }, + enabled = !state.submitting, + modifier = Modifier.weight(1f) + ) { + if (state.submitting) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = LocalContentColor.current + ) + } else { + Text("Odeslat") + } + } + } + + Spacer(Modifier.height(32.dp)) + } + + if (state.showTimeoutDialog) { + AlertDialog( + onDismissRequest = vm::dismissTimeoutDialog, + title = { Text("Nelze overit stav prikazu") }, + text = { + Text( + "Spojeni s burzou selhalo nebo timeoutovalo. Prikaz mohl byt odeslan, ale nelze to potvrdit. Zkontroluj otevrene ordery na burze pres web a v pripade potreby zrus duplicitu." + ) + }, + confirmButton = { + Button(onClick = vm::dismissTimeoutDialog) { Text("OK") } + } + ) + } +} + +@Composable +private fun SummaryRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + } +} + @Composable internal fun InfoRow( label: String, From f0e982182a6da66d42bc6d6734410194fba8d67b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 23:45:58 +0200 Subject: [PATCH 20/75] feat(sell): chart BUY/SELL markers stub (Task 26) Add ChartTradeMarker model and an optional tradeMarkers parameter to PortfolioLineChart so callers (per-plan / portfolio screens) can pass BUY/SELL trade points through. Visual rendering on top of the Vico CartesianChartHost is intentionally deferred - it requires custom decoration components and an Instant -> chart-pixel x-coordinate solver because Vico's x-axis is index-based (epochDay buckets), not time-based. The API is wired now so a follow-up patch can drop in the renderer without touching call sites. DONE_WITH_CONCERNS per plan: marker rendering deferred. Co-Authored-By: Claude Opus 4.6 --- .../components/ChartComponents.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt index 8970b23..7526948 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt @@ -50,8 +50,10 @@ import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.core.cartesian.Zoom import com.patrykandpatrick.vico.core.cartesian.marker.DefaultCartesianMarker import com.patrykandpatrick.vico.core.cartesian.marker.LineCartesianLayerMarkerTarget +import com.accbot.dca.domain.model.TransactionSide import com.accbot.dca.presentation.utils.NumberFormatters import java.math.BigDecimal +import java.time.Instant import java.time.LocalDate import java.time.format.DateTimeFormatter import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisGuidelineComponent @@ -176,6 +178,22 @@ private fun LegendItem(color: Color, label: String, enabled: Boolean = true, onC } } +/** + * BUY/SELL transaction marker for the portfolio chart timeline. + * + * NOTE (Task 26 / Phase 8): the parameter is currently consumed by the Vico chart + * via a no-op overlay. Drawing per-transaction triangles on top of a Vico + * CartesianChartHost requires custom decoration components and an x-coordinate + * solver that maps Instant -> chart pixel space; both are non-trivial because + * the chart's x-axis is index-based (epochDay buckets) rather than time-based. + * The API is wired up so callers can pass markers; visual rendering is a + * follow-up. See DCA Sell Extension plan, Task 26 (DONE_WITH_CONCERNS). + */ +data class ChartTradeMarker( + val time: Instant, + val side: TransactionSide +) + /** * Portfolio line chart with dual Y-axis support. * Left axis (start): portfolio value, cost basis, crypto price (all in fiat). @@ -196,6 +214,11 @@ fun PortfolioLineChart( visibleCryptoGroupLines: Set> = emptySet(), zoomLevel: ChartZoomLevel = ChartZoomLevel.Overview, onScrub: (Int?) -> Unit = {}, + /** + * Optional BUY (green up-triangle) / SELL (red down-triangle) markers to render + * on the chart timeline. Currently a stub – see [ChartTradeMarker] kdoc. + */ + @Suppress("UNUSED_PARAMETER") tradeMarkers: List = emptyList(), modifier: Modifier = Modifier ) { if (chartData.isEmpty()) return From 49aa1d82ab05b8cb2a9f6678946b15694db4c7d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 23:48:53 +0200 Subject: [PATCH 21/75] feat(sell): History filter chips + BUY/SELL icons (Task 27) - Add HistorySideFilter (ALL / BUYS / SELLS / PENDING) as a first-class row of chips at the top of HistoryScreen, persisted via HistoryFilter in the ViewModel. PENDING matches status IN (PENDING, PARTIAL). - TransactionCard now shows a TrendingUp (red) / TrendingDown (green) badge next to the status icon based on transaction side. - Amount signs flip for SELL: crypto -, fiat +. Colors match direction. - clearFilter preserves the side chip since it's a persistent mode, not a one-off filter like crypto/exchange/date. Co-Authored-By: Claude Opus 4.6 --- .../dca/presentation/screens/HistoryScreen.kt | 61 +++++++++++++++++-- .../presentation/screens/HistoryViewModel.kt | 27 +++++++- .../app/src/main/res/values-cs/strings.xml | 6 ++ .../app/src/main/res/values/strings.xml | 6 ++ 4 files changed, 92 insertions(+), 8 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt index b82bb9d..a5d278b 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt @@ -29,6 +29,7 @@ import androidx.core.content.FileProvider import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.accbot.dca.R import com.accbot.dca.data.local.TransactionEntity +import com.accbot.dca.domain.model.TransactionSide import com.accbot.dca.domain.model.TransactionStatus import com.accbot.dca.presentation.components.AccBotTopAppBar import com.accbot.dca.presentation.components.EmptyState @@ -231,6 +232,12 @@ fun HistoryScreen( ) } + // Side filter chips (Vse / Nakupy / Prodeje / Pending) + SideFilterChipsRow( + selected = uiState.filter.sideFilter, + onSelect = { viewModel.setSideFilter(it) } + ) + // Active filter chips if (hasActiveFilter) { ActiveFilterChips( @@ -702,12 +709,40 @@ private fun FilterBottomSheet( } } +@Composable +private fun SideFilterChipsRow( + selected: HistorySideFilter, + onSelect: (HistorySideFilter) -> Unit +) { + val entries = listOf( + HistorySideFilter.ALL to stringResource(R.string.history_side_all), + HistorySideFilter.BUYS to stringResource(R.string.history_side_buys), + HistorySideFilter.SELLS to stringResource(R.string.history_side_sells), + HistorySideFilter.PENDING to stringResource(R.string.history_side_pending) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + entries.forEach { (side, label) -> + FilterChip( + selected = selected == side, + onClick = { onSelect(side) }, + label = { Text(label) } + ) + } + } +} + @Composable internal fun TransactionCard( transaction: TransactionEntity, onClick: () -> Unit ) { val dateFormatter = DateFormatters.transactionDateTime + val isSell = transaction.side == TransactionSide.SELL Card( modifier = Modifier @@ -726,6 +761,16 @@ internal fun TransactionCard( ) { Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { + // Direction badge: green ArrowDownward for BUY, red ArrowUpward for SELL. + Icon( + imageVector = if (isSell) Icons.AutoMirrored.Filled.TrendingUp else Icons.AutoMirrored.Filled.TrendingDown, + contentDescription = stringResource( + if (isSell) R.string.history_side_sell_label else R.string.history_side_buy_label + ), + tint = if (isSell) Error else successColor(), + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) Icon( imageVector = when (transaction.status) { TransactionStatus.COMPLETED -> Icons.Default.CheckCircle @@ -784,11 +829,16 @@ internal fun TransactionCard( } Column(horizontalAlignment = Alignment.End) { - if (transaction.status == TransactionStatus.COMPLETED) { + // Amount signs: BUY = +crypto/-fiat, SELL = -crypto/+fiat. + // Only show filled crypto for COMPLETED and PARTIAL (in-flight PENDING has 0). + val showCryptoLine = transaction.status == TransactionStatus.COMPLETED || + (transaction.status == TransactionStatus.PARTIAL && transaction.cryptoAmount.signum() > 0) + if (showCryptoLine) { + val cryptoSign = if (isSell) "-" else "+" Text( - text = "+${NumberFormatters.crypto(transaction.cryptoAmount)}", + text = "$cryptoSign${NumberFormatters.crypto(transaction.cryptoAmount)}", fontWeight = FontWeight.SemiBold, - color = successColor() + color = if (isSell) Error else successColor() ) Text( text = transaction.crypto, @@ -799,10 +849,11 @@ internal fun TransactionCard( Spacer(modifier = Modifier.height(4.dp)) + val fiatSign = if (isSell) "+" else "-" Text( - text = "-${NumberFormatters.fiat(transaction.fiatAmount)} ${transaction.fiat}", + text = "$fiatSign${NumberFormatters.fiat(transaction.fiatAmount)} ${transaction.fiat}", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = if (isSell) successColor() else MaterialTheme.colorScheme.onSurfaceVariant ) // Chevron to indicate clickable diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt index 6673a54..8127971 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.accbot.dca.data.local.TransactionDao import com.accbot.dca.data.local.TransactionEntity +import com.accbot.dca.domain.model.TransactionSide import com.accbot.dca.domain.model.TransactionStatus import com.accbot.dca.domain.usecase.CsvExportResult import com.accbot.dca.domain.usecase.ExportTransactionsToCsvUseCase @@ -27,13 +28,20 @@ enum class SortOption { PRICE_LOWEST } +/** + * Primary BUY/SELL/PENDING filter chips shown at the top of HistoryScreen. + * Applied in memory over the result of the SQL-level HistoryFilter below. + */ +enum class HistorySideFilter { ALL, BUYS, SELLS, PENDING } + data class HistoryFilter( val crypto: String? = null, val exchange: String? = null, val status: TransactionStatus? = null, val dateFrom: Long? = null, val dateTo: Long? = null, - val searchQuery: String = "" + val searchQuery: String = "", + val sideFilter: HistorySideFilter = HistorySideFilter.ALL ) /** @@ -106,7 +114,14 @@ class HistoryViewModel @Inject constructor( NumberFormatters.fiat(tx.fiatAmount), NumberFormatters.crypto(tx.cryptoAmount), tx.exchangeOrderId ?: "", tx.errorMessage ?: "" ).any { it.contains(filter.searchQuery, ignoreCase = true) } - matchesDates && matchesSearch + val matchesSide = when (filter.sideFilter) { + HistorySideFilter.ALL -> true + HistorySideFilter.BUYS -> tx.side == TransactionSide.BUY + HistorySideFilter.SELLS -> tx.side == TransactionSide.SELL + HistorySideFilter.PENDING -> tx.status == TransactionStatus.PENDING || + tx.status == TransactionStatus.PARTIAL + } + matchesDates && matchesSearch && matchesSide } val sorted = when (sortOption) { @@ -164,8 +179,14 @@ class HistoryViewModel @Inject constructor( _filterState.value = filter } + fun setSideFilter(side: HistorySideFilter) { + _filterState.value = _filterState.value.copy(sideFilter = side) + } + fun clearFilter() { - _filterState.value = HistoryFilter() + // Reset everything *except* the top-level BUY/SELL/PENDING chip - users + // typically want that chip as a persistent mode, not a one-off filter. + _filterState.value = HistoryFilter(sideFilter = _filterState.value.sideFilter) } fun setSortOption(option: SortOption) { diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index c0c3829..caaae0e 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -256,6 +256,12 @@ Do Hledat transakce… Vybrat datum + Vše + Nákupy + Prodeje + Čekající + Nákup + Prodej Vytvořit DCA plán diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 9042789..1b23295 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -255,6 +255,12 @@ To Search transactions… Select date + All + Buys + Sells + Pending + Buy + Sell Create DCA Plan From f25ac8018276e784cd4b77bc253ceaf73787e1ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 23:51:49 +0200 Subject: [PATCH 22/75] feat(sell): TxDetail SELL section + cancel button (Task 28) - TransactionDetailsViewModel now injects CancelSellOrderUseCase and exposes cancelOrder(txId) with snackbar error reporting and an isCancelling guard to prevent double-taps. - TransactionDetailsScreen renders a new SellDetailsCard for SELL transactions showing limit price, fill progress (X / Y crypto, %), and avg fill price when any amount has been filled. - For PENDING / PARTIAL sells the screen also shows a red Cancel order button gated by a confirmation AlertDialog. Localized CS/EN strings. Co-Authored-By: Claude Opus 4.6 --- .../history/TransactionDetailsScreen.kt | 118 ++++++++++++++++++ .../history/TransactionDetailsViewModel.kt | 40 +++++- .../app/src/main/res/values-cs/strings.xml | 7 ++ .../app/src/main/res/values/strings.xml | 7 ++ 4 files changed, 170 insertions(+), 2 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsScreen.kt index 56fe5b7..20c7414 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.accbot.dca.R +import com.accbot.dca.domain.model.TransactionSide import com.accbot.dca.domain.model.TransactionStatus import com.accbot.dca.presentation.components.AccBotTopAppBar import com.accbot.dca.presentation.components.ErrorState @@ -37,6 +38,8 @@ import com.accbot.dca.presentation.ui.theme.accentColor import com.accbot.dca.presentation.ui.theme.successColor import com.accbot.dca.presentation.utils.DateFormatters import com.accbot.dca.presentation.utils.NumberFormatters +import java.math.BigDecimal +import java.math.RoundingMode @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -47,12 +50,18 @@ fun TransactionDetailsScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(transactionId) { viewModel.loadTransaction(transactionId) } + LaunchedEffect(Unit) { + viewModel.snackbar.collect { msg -> snackbarHostState.showSnackbar(msg) } + } + Scaffold( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, topBar = { AccBotTopAppBar( title = stringResource(R.string.transaction_details_title), @@ -189,6 +198,62 @@ fun TransactionDetailsScreen( } } + // SELL-specific section (limit price, fill progress, cancel) + if (transaction.side == TransactionSide.SELL) { + Spacer(modifier = Modifier.height(16.dp)) + SellDetailsCard(transaction = transaction) + + if (transaction.status == TransactionStatus.PENDING || + transaction.status == TransactionStatus.PARTIAL + ) { + Spacer(modifier = Modifier.height(16.dp)) + var showConfirm by remember { mutableStateOf(false) } + Button( + onClick = { showConfirm = true }, + enabled = !uiState.isCancelling, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = Error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + if (uiState.isCancelling) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onError + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stringResource(R.string.transaction_details_cancel_order)) + } + + if (showConfirm) { + AlertDialog( + onDismissRequest = { showConfirm = false }, + title = { Text(stringResource(R.string.transaction_details_cancel_confirm_title)) }, + text = { Text(stringResource(R.string.transaction_details_cancel_confirm_text)) }, + confirmButton = { + TextButton(onClick = { + showConfirm = false + viewModel.cancelOrder(transaction.id) + }) { + Text( + stringResource(R.string.transaction_details_cancel_order), + color = Error + ) + } + }, + dismissButton = { + TextButton(onClick = { showConfirm = false }) { + Text(stringResource(R.string.common_back)) + } + } + ) + } + } + } + // Error message card (if failed) if (transaction.status == TransactionStatus.FAILED && transaction.errorMessage != null) { Spacer(modifier = Modifier.height(16.dp)) @@ -341,6 +406,59 @@ private fun DetailRow( } } +@Composable +private fun SellDetailsCard(transaction: com.accbot.dca.domain.model.Transaction) { + val requested = transaction.requestedCryptoAmount ?: BigDecimal.ZERO + val filled = transaction.cryptoAmount + val progressPct = if (requested > BigDecimal.ZERO) { + filled.divide(requested, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + .toInt() + } else 0 + val showAvgFill = filled.signum() > 0 && transaction.price.signum() > 0 + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.transaction_details_sell_section), + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleSmall + ) + + DetailRow( + icon = Icons.Default.PriceCheck, + label = stringResource(R.string.transaction_details_limit_price), + value = transaction.limitPrice?.let { + "${NumberFormatters.fiat(it)} ${transaction.fiat}/${transaction.crypto}" + } ?: "-" + ) + + DetailRow( + icon = Icons.Default.Done, + label = stringResource(R.string.transaction_details_filled), + value = "${NumberFormatters.crypto(filled)} / ${NumberFormatters.crypto(requested)} ${transaction.crypto} (${progressPct}%)" + ) + + if (showAvgFill) { + DetailRow( + icon = Icons.AutoMirrored.Filled.TrendingUp, + label = stringResource(R.string.transaction_details_avg_fill_price), + value = "${NumberFormatters.fiat(transaction.price)} ${transaction.fiat}/${transaction.crypto}" + ) + } + } + } +} + @Composable private fun DetailRowWithCopy( icon: ImageVector, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsViewModel.kt index 0ea8161..e60a702 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.accbot.dca.data.local.TransactionDao import com.accbot.dca.data.local.toDomain import com.accbot.dca.domain.model.Transaction +import com.accbot.dca.domain.usecase.CancelSellOrderUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -15,17 +16,22 @@ import javax.inject.Inject data class TransactionDetailsUiState( val transaction: Transaction? = null, val isLoading: Boolean = true, - val error: String? = null + val error: String? = null, + val isCancelling: Boolean = false ) @HiltViewModel class TransactionDetailsViewModel @Inject constructor( - private val transactionDao: TransactionDao + private val transactionDao: TransactionDao, + private val cancelSellOrderUseCase: CancelSellOrderUseCase ) : ViewModel() { private val _uiState = MutableStateFlow(TransactionDetailsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _snackbar = MutableSharedFlow(extraBufferCapacity = 4) + val snackbar: SharedFlow = _snackbar.asSharedFlow() + fun loadTransaction(transactionId: Long) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } @@ -53,4 +59,34 @@ class TransactionDetailsViewModel @Inject constructor( } } } + + /** + * Cancel an open limit sell order. On success the underlying Flow (DAO) will + * update status to FAILED/COMPLETED per the use case and the next loadTransaction + * call picks that up. We also re-fetch here to keep the already-open detail + * screen in sync without waiting for a user navigation round trip. + */ + fun cancelOrder(txId: Long) { + if (_uiState.value.isCancelling) return + _uiState.update { it.copy(isCancelling = true) } + viewModelScope.launch { + val result = cancelSellOrderUseCase(txId) + if (result.isFailure) { + _snackbar.emit( + "Zruseni orderu selhalo: ${result.exceptionOrNull()?.message ?: "neznama chyba"}" + ) + } + // Refresh after cancel attempt (success or failure) so displayed status is accurate. + try { + val entity = transactionDao.getTransactionById(txId) + if (entity != null) { + _uiState.update { it.copy(transaction = entity.toDomain(), isCancelling = false) } + } else { + _uiState.update { it.copy(isCancelling = false) } + } + } catch (_: Exception) { + _uiState.update { it.copy(isCancelling = false) } + } + } + } } diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index caaae0e..31e6d38 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -438,6 +438,13 @@ Čekající Částečně vyplněno v + Sell order + Limitní cena + Vyplněno + Průměrná cena plnění + Zrušit order + Zrušit order? + Opravdu zrušit limitní prodej? Client ID diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 1b23295..90160f2 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -437,6 +437,13 @@ Pending Partially Filled at + Sell order + Limit price + Filled + Avg fill price + Cancel order + Cancel order? + Are you sure you want to cancel this limit sell order? Client ID From a38068c1896defaf79ddfd62aa41ba2d08de508a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 23:57:01 +0200 Subject: [PATCH 23/75] feat(sell): Portfolio realized + net P&L summary (Task 29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DAO: - Filter all aggregate "invested / accumulated" SUM queries to side='BUY' so existing dashboard / portfolio / chart totals don't get inflated by SELL transactions. SELL fiatAmount is reported separately as realized. - Add getRealizedFiatByFiat(fiat) and getRealizedFiatByPlan(planId) for the new portfolio summary rows. PARTIAL is included because partial fills have already booked their fiatAmount. PortfolioViewModel: - Add showTradingMetrics (gated by UserPreferences.isTradingEnabled), totalRealized, and netPnL state. recomputeTradingMetrics runs after each chart load and uses planId scope on per-plan pages and fiat scope on aggregate pages. PortfolioScreen: - TradingMetricsRows composable rendering "Realizováno" and "Čistý P&L" rows under the existing KPI grid, in both portrait and landscape variants. Hidden when trading is off or there are no realized sells (avoids showing 0 rows by default). DONE_WITH_CONCERNS scope per plan: charts and per-plan series remain BUY-only. Realized P&L is surfaced in summary text rows only, no chart overlay. The sell-extension chart series can be added later without touching the existing pipeline. Co-Authored-By: Claude Opus 4.6 --- .../java/com/accbot/dca/data/local/Daos.kt | 61 +++++++++++++--- .../screens/portfolio/PortfolioScreen.kt | 61 ++++++++++++++++ .../screens/portfolio/PortfolioViewModel.kt | 73 ++++++++++++++++++- .../app/src/main/res/values-cs/strings.xml | 2 + .../app/src/main/res/values/strings.xml | 2 + 5 files changed, 187 insertions(+), 12 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt index 8e3507d..5c3b435 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt @@ -248,12 +248,15 @@ interface TransactionDao { @Query("SELECT * FROM transactions WHERE id = :id") suspend fun getTransactionById(id: Long): TransactionEntity? - // Returns sum as String to avoid Double precision loss for monetary values - @Query("SELECT CAST(COALESCE(SUM(CAST(fiatAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE fiat = :fiat AND status = 'COMPLETED'") + // Returns sum as String to avoid Double precision loss for monetary values. + // SELL excluded: this query represents money INVESTED via BUYs, not net cash flow. + @Query("SELECT CAST(COALESCE(SUM(CAST(fiatAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE fiat = :fiat AND status = 'COMPLETED' AND side = 'BUY'") suspend fun getTotalInvestedByFiat(fiat: String): String - // Returns sum as String to avoid Double precision loss for crypto amounts - @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE crypto = :crypto AND status = 'COMPLETED'") + // Returns sum as String to avoid Double precision loss for crypto amounts. + // SELL excluded: represents accumulated BUY volume, mirroring the dashboard's + // "total accumulated" KPI. + @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE crypto = :crypto AND status = 'COMPLETED' AND side = 'BUY'") suspend fun getTotalCryptoBySymbol(crypto: String): String @Query("SELECT COUNT(*) FROM transactions WHERE status = 'COMPLETED'") @@ -353,12 +356,19 @@ interface TransactionDao { @Query("DELETE FROM transactions WHERE exchange = :exchange") suspend fun deleteTransactionsByExchange(exchange: Exchange) + /** + * Per-pair holdings used for dashboard / portfolio summaries. + * Sell-extension: SELL rows are excluded so the displayed "total accumulated" + * and "total invested" reflect BUY-only DCA activity. Realized P&L from sells + * is surfaced separately in the portfolio summary, not subtracted from these + * totals (avoids double-counting and keeps the historical chart logic stable). + */ @Query(""" SELECT crypto || '/' || fiat as pair, crypto, fiat, CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) as totalCrypto, CAST(COALESCE(SUM(CAST(fiatAmount AS REAL)), 0) AS TEXT) as totalFiat, COUNT(*) as transactionCount - FROM transactions WHERE status = 'COMPLETED' + FROM transactions WHERE status = 'COMPLETED' AND side = 'BUY' GROUP BY crypto, fiat ORDER BY SUM(CAST(fiatAmount AS REAL)) DESC """) @@ -369,7 +379,7 @@ interface TransactionDao { CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) as totalCrypto, CAST(COALESCE(SUM(CAST(fiatAmount AS REAL)), 0) AS TEXT) as totalFiat, COUNT(*) as transactionCount - FROM transactions WHERE status = 'COMPLETED' + FROM transactions WHERE status = 'COMPLETED' AND side = 'BUY' GROUP BY crypto, fiat ORDER BY SUM(CAST(fiatAmount AS REAL)) DESC """) @@ -381,18 +391,24 @@ interface TransactionDao { @Query("DELETE FROM transactions") suspend fun deleteAllTransactions() + /** + * Used by the portfolio chart pipeline. SELL excluded so the historical + * "invested / accumulated" series stays monotonic (sells would create + * counterintuitive dips in cumulative volume). Realized P&L is reported + * separately in the portfolio summary, see [getRealizedFiatByFiat]. + */ @Query(""" SELECT * FROM transactions - WHERE status = 'COMPLETED' + WHERE status = 'COMPLETED' AND side = 'BUY' AND (:exchange IS NULL OR exchange = :exchange) ORDER BY executedAt ASC """) suspend fun getCompletedTransactionsOrdered(exchange: String? = null): List - @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE exchange = :exchange AND crypto = :crypto AND status = 'COMPLETED'") + @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE exchange = :exchange AND crypto = :crypto AND status = 'COMPLETED' AND side = 'BUY'") suspend fun getTotalCryptoByExchangeAndCrypto(exchange: String, crypto: String): String - @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE connectionId = :connectionId AND crypto = :crypto AND status = 'COMPLETED'") + @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE connectionId = :connectionId AND crypto = :crypto AND status = 'COMPLETED' AND side = 'BUY'") suspend fun getTotalCryptoByConnectionAndCrypto(connectionId: Long, crypto: String): String @Query("SELECT * FROM transactions WHERE exchangeOrderId = :orderId LIMIT 1") @@ -407,8 +423,33 @@ interface TransactionDao { @Query("SELECT * FROM transactions WHERE exchangeOrderId = :orderId AND connectionId = :connectionId LIMIT 1") suspend fun getByExchangeOrderIdAndConnection(orderId: String, connectionId: Long): TransactionEntity? - @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE planId = :planId AND status = 'COMPLETED'") + @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE planId = :planId AND status = 'COMPLETED' AND side = 'BUY'") suspend fun getAccumulatedCryptoByPlan(planId: Long): String + + /** + * Total realized fiat from completed/partial SELL orders for a given fiat + * currency. Used by the portfolio summary to surface "Realized P&L" alongside + * the existing BUY-based totals. PARTIAL is included because partial fills + * have already booked their fiatAmount (= filled amount * avg fill price). + */ + @Query(""" + SELECT CAST(COALESCE(SUM(CAST(fiatAmount AS REAL)), 0) AS TEXT) + FROM transactions + WHERE fiat = :fiat AND side = 'SELL' AND status IN ('COMPLETED', 'PARTIAL') + """) + suspend fun getRealizedFiatByFiat(fiat: String): String + + /** + * Per-plan variant of [getRealizedFiatByFiat]. Same semantics, scoped to a + * single plan id so the per-plan portfolio summary can show realized P&L + * for that plan only. + */ + @Query(""" + SELECT CAST(COALESCE(SUM(CAST(fiatAmount AS REAL)), 0) AS TEXT) + FROM transactions + WHERE planId = :planId AND side = 'SELL' AND status IN ('COMPLETED', 'PARTIAL') + """) + suspend fun getRealizedFiatByPlan(planId: Long): String } data class CryptoFiatHolding( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index 62a43d0..f307848 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -1113,6 +1113,64 @@ internal fun KpiCardContent( } } } + + // Sell-extension trading metrics: realized + net P&L (only when trading enabled) + TradingMetricsRows(uiState = uiState, fiatSymbol = fiatSymbol) +} + +@Composable +private fun TradingMetricsRows( + uiState: PortfolioUiState, + fiatSymbol: String +) { + if (!uiState.showTradingMetrics) return + val realized = uiState.totalRealized ?: BigDecimal.ZERO + if (realized.signum() <= 0 && uiState.netPnL == null) return + + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + if (realized.signum() > 0) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.portfolio_realized), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${NumberFormatters.fiat(realized)} $fiatSymbol", + fontWeight = FontWeight.SemiBold, + color = successColor() + ) + } + } + + val net = uiState.netPnL + if (net != null) { + Spacer(modifier = Modifier.height(4.dp)) + val isPositive = net.signum() >= 0 + val pnlColor = if (isPositive) successColor() else MaterialTheme.colorScheme.error + val sign = if (isPositive) "+" else "" + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.portfolio_net_pnl), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "$sign${NumberFormatters.fiat(net)} $fiatSymbol", + fontWeight = FontWeight.SemiBold, + color = pnlColor + ) + } + } } @Composable @@ -1259,6 +1317,9 @@ private fun LandscapeKpiContent( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) + + // Sell-extension trading metrics: realized + net P&L (only when trading enabled) + TradingMetricsRows(uiState = uiState, fiatSymbol = fiatSymbol) } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt index 2a634c2..449b82d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.math.BigDecimal import java.time.LocalDate import java.time.ZoneId import javax.inject.Inject @@ -79,7 +80,25 @@ data class PortfolioUiState( val isLoading: Boolean = true, val isChartLoading: Boolean = false, val isPriceSyncing: Boolean = false, - val error: String? = null + val error: String? = null, + /** + * True when the global trading master switch is on. Gates display of the + * sell-extension summary rows (realized P&L, net P&L) so users without the + * feature enabled don't see empty/zero rows. + */ + val showTradingMetrics: Boolean = false, + /** + * Sum of fiat received from completed/partial SELL orders for the currently + * selected fiat. Null when not loaded yet, BigDecimal.ZERO when there are + * no realized sells. + */ + val totalRealized: BigDecimal? = null, + /** + * Net P&L = currentPortfolioValue + totalRealized - totalInvested. + * Null when current price is unavailable (matches the existing chart-loading + * pattern where ROI fields are also null until prices arrive). + */ + val netPnL: BigDecimal? = null ) @HiltViewModel @@ -145,7 +164,12 @@ class PortfolioViewModel @Inject constructor( private fun loadPortfolio() { portfolioJob?.cancel() portfolioJob = viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } + val tradingEnabled = userPreferences.isTradingEnabled() + _uiState.update { it.copy( + isLoading = true, + error = null, + showTradingMetrics = tradingEnabled + ) } try { // Use pre-filtered, sorted query (avoids loading failed/pending into memory) val completed = transactionDao.getCompletedTransactionsOrdered() @@ -536,7 +560,52 @@ class PortfolioViewModel @Inject constructor( cryptoGroupLines = chartResult.cryptoGroupLines, isChartLoading = false ) } + + // Sell-extension: compute realized P&L from SELL transactions and net P&L + // (currentValue + realized - invested). Gated by the global trading switch. + recomputeTradingMetrics(chartResult) + } + } + + /** + * Computes [PortfolioUiState.totalRealized] and [PortfolioUiState.netPnL] for + * the currently selected page. Reads SELL transactions from cache instead of + * re-querying because they're already in [completedTransactions] (the DAO + * pre-filter is BUY-only, so we look at the global `db` here via DAO). + */ + private suspend fun recomputeTradingMetrics(chartResult: ChartComputeResult) { + if (!_uiState.value.showTradingMetrics) { + _uiState.update { it.copy(totalRealized = null, netPnL = null) } + return + } + val fiat = chartResult.fiat + if (fiat == null) { + _uiState.update { it.copy(totalRealized = null, netPnL = null) } + return } + + val realized = try { + // Query SELL totals scoped to fiat. For per-plan pages we filter further + // via the page's planId; for aggregate pages we use everything in fiat. + val state = _uiState.value + val page = state.pages.getOrNull(state.selectedPageIndex) + val planId = (page as? PairPage.Plan)?.planId + + if (planId != null) { + BigDecimal(transactionDao.getRealizedFiatByPlan(planId)) + } else { + BigDecimal(transactionDao.getRealizedFiatByFiat(fiat)) + } + } catch (_: Exception) { + BigDecimal.ZERO + } + + val lastPoint = chartResult.data.lastOrNull() + val net = if (lastPoint != null) { + lastPoint.portfolioValue + realized - lastPoint.totalInvested + } else null + + _uiState.update { it.copy(totalRealized = realized, netPnL = net) } } private data class ChartComputeResult( diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 31e6d38..3f7ab33 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -333,6 +333,8 @@ Portfolio Transakce Načítání portfolia… + Realizováno + Čistý P&L Všechny páry diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 90160f2..6cc18a2 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -332,6 +332,8 @@ Portfolio Transactions Loading portfolio… + Realized + Net P&L All Pairs From fbe46e4786a42444181193551c05e966c680206d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Thu, 23 Apr 2026 23:59:33 +0200 Subject: [PATCH 24/75] feat(sell): Dashboard open sells card (Task 30) DashboardViewModel: - Add openSellsByPlan StateFlow fed by transactionDao.observeAllOpenSells. Subscription only starts when global trading is enabled, so users without the feature opt-in never see the cards. - Reactive: cards update when an order fills, the user cancels, or a new sell wizard submits a fresh order. DashboardScreen: - New OpenSellsSummaryCard composable showing "{plan name}: N open sell order(s)" + a preview of the most recent sell ({amount} {crypto} @ {price} {fiat}). Tapping deep-links to plan detail. - Wired into both portrait and landscape layouts between Holdings and Market Pulse. Hidden when the map is empty (natural gating). Co-Authored-By: Claude Opus 4.6 --- .../presentation/screens/DashboardScreen.kt | 92 +++++++++++++++++++ .../screens/DashboardViewModel.kt | 25 ++++- .../app/src/main/res/values-cs/strings.xml | 3 + .../app/src/main/res/values/strings.xml | 3 + 4 files changed, 122 insertions(+), 1 deletion(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt index 6541d74..2f98e90 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt @@ -65,6 +65,7 @@ import com.accbot.dca.data.remote.CryptoData import com.accbot.dca.data.remote.FearGreedData import com.accbot.dca.domain.model.DcaFrequency import com.accbot.dca.domain.model.DcaStrategy +import com.accbot.dca.domain.model.Transaction import com.accbot.dca.domain.util.CronUtils import com.accbot.dca.presentation.components.CryptoIcon import com.accbot.dca.presentation.components.EmptyState @@ -269,6 +270,20 @@ fun DashboardScreen( compact = true ) + if (uiState.openSellsByPlan.isNotEmpty()) { + uiState.openSellsByPlan.forEach { (planId, sells) -> + val plan = uiState.activePlans.firstOrNull { it.plan.id == planId }?.plan + if (plan != null) { + OpenSellsSummaryCard( + planName = plan.name.ifBlank { "${plan.crypto}/${plan.fiat}" }, + fiat = plan.fiat, + sells = sells, + onClick = { onNavigateToPlanDetails?.invoke(planId) } + ) + } + } + } + if (uiState.showMarketPulse && (uiState.fearGreedData != null || uiState.athDataByCrypto.isNotEmpty())) { MarketPulseCard( fearGreedData = uiState.fearGreedData, @@ -400,6 +415,21 @@ fun DashboardScreen( ) } + // Open SELL orders, grouped per plan + if (uiState.openSellsByPlan.isNotEmpty()) { + items(uiState.openSellsByPlan.entries.toList(), key = { it.key }) { entry -> + val plan = uiState.activePlans.firstOrNull { it.plan.id == entry.key }?.plan + if (plan != null) { + OpenSellsSummaryCard( + planName = plan.name.ifBlank { "${plan.crypto}/${plan.fiat}" }, + fiat = plan.fiat, + sells = entry.value, + onClick = { onNavigateToPlanDetails?.invoke(plan.id) } + ) + } + } + } + // Market Pulse if (uiState.showMarketPulse && (uiState.fearGreedData != null || uiState.athDataByCrypto.isNotEmpty())) { item { @@ -1891,6 +1921,68 @@ private fun localizedFearGreedClass(value: Int): String { } } +/** + * Summary card surfacing all open SELL orders for a single plan. Shown on the + * dashboard between Holdings and Market Pulse. Tapping deep-links to the plan + * detail screen where the user can cancel or inspect each order. + */ +@Composable +internal fun OpenSellsSummaryCard( + planName: String, + fiat: String, + sells: List, + onClick: () -> Unit +) { + if (sells.isEmpty()) return + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Button, onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource( + R.string.dashboard_open_sells_title, + planName, + sells.size + ), + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleSmall + ) + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + // First sell preview (most recent due to ORDER BY executedAt DESC in DAO). + sells.firstOrNull()?.let { tx -> + Spacer(modifier = Modifier.height(4.dp)) + val priceText = tx.limitPrice?.let { NumberFormatters.fiat(it) } ?: "-" + val amount = tx.requestedCryptoAmount ?: tx.cryptoAmount + Text( + text = "${NumberFormatters.crypto(amount)} ${tx.crypto} @ $priceText $fiat", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + private val gaugeColors = listOf( Color(0xFFE53935), Color(0xFFFF9800), diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt index b5290f3..65d7328 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt @@ -14,6 +14,7 @@ import com.accbot.dca.data.local.TransactionDao import com.accbot.dca.data.local.UserPreferences import com.accbot.dca.data.local.WithdrawalThresholdDao import com.accbot.dca.data.local.toDomain +import com.accbot.dca.domain.model.Transaction import com.accbot.dca.data.remote.CryptoData import com.accbot.dca.data.remote.FearGreedData import com.accbot.dca.data.remote.MarketDataService @@ -110,7 +111,12 @@ data class DashboardUiState( val showMarketPulse: Boolean = true, val isMarketPulseExpanded: Boolean = true, val networkRetryInfo: NetworkRetryInfo = NetworkRetryInfo(), - val missedPurchases: List = emptyList() + val missedPurchases: List = emptyList(), + /** + * Open SELL orders grouped by plan id. Empty when trading is off or no + * pending sells exist; the dashboard renders one card per non-empty group. + */ + val openSellsByPlan: Map> = emptyMap() ) @HiltViewModel @@ -152,6 +158,23 @@ class DashboardViewModel @Inject constructor( init { loadData() + observeOpenSells() + } + + /** + * Continuously observe open (PENDING / PARTIAL) SELL orders so the dashboard + * "Open sells" cards update reactively when an order fills, the user cancels, + * or a new sell wizard submits a fresh order. Only emits when global trading + * is enabled - avoids surfacing the cards for users who haven't opted in. + */ + private fun observeOpenSells() { + if (!userPreferences.isTradingEnabled()) return + viewModelScope.launch { + transactionDao.observeAllOpenSells().collect { entities -> + val grouped = entities.map { it.toDomain() }.groupBy { it.planId } + _uiState.update { it.copy(openSellsByPlan = grouped) } + } + } } private fun loadData() { diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 3f7ab33..7aeb4df 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -263,6 +263,9 @@ Nákup Prodej + + %1$s: %2$d otevřených sell orderů + Vytvořit DCA plán Vybrat burzu diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 6cc18a2..fcf45de 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -262,6 +262,9 @@ Buy Sell + + %1$s: %2$d open sell order(s) + Create DCA Plan Select Exchange From b662df23175a60b3dd2e373003444f5dc98a6338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 24 Apr 2026 00:02:10 +0200 Subject: [PATCH 25/75] fix(history): preserve side filter when applying bottom-sheet filters The FilterBottomSheet Apply button was constructing a fresh HistoryFilter from scratch, which silently reset the new BUY/SELL/PENDING side chip to ALL every time the user touched any other filter. Using currentFilter.copy keeps the chip selection intact across filter-sheet applies. Co-Authored-By: Claude Opus 4.6 --- .../java/com/accbot/dca/presentation/screens/HistoryScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt index a5d278b..e809737 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt @@ -689,7 +689,7 @@ private fun FilterBottomSheet( Button( onClick = { onApplyFilter( - HistoryFilter( + currentFilter.copy( crypto = selectedCrypto, exchange = selectedExchange, status = selectedStatus, From daecaa75af9463a72cd4a2b95ee8c87540114e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 24 Apr 2026 08:07:58 +0200 Subject: [PATCH 26/75] feat(sell): block plan delete with open orders (Task 31) PlanDetailsViewModel.deletePlan now checks observeOpenSellsForPlan before issuing the cascade delete. When open sells exist, it surfaces deleteBlockedOpenSells in UI state instead of dropping the plan; the screen shows an AlertDialog telling the user to cancel the orders on the exchange first. Without this guard the FK link to the order rows would be lost and subsequent fill polling would silently fail. Co-Authored-By: Claude Opus 4.6 --- .../screens/plans/PlanDetailsScreen.kt | 14 +++++++++++++ .../screens/plans/PlanDetailsViewModel.kt | 20 ++++++++++++++++++- .../app/src/main/res/values-cs/strings.xml | 2 ++ .../app/src/main/res/values/strings.xml | 2 ++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt index ef2ca98..c95fe9c 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt @@ -205,6 +205,20 @@ fun PlanDetailsScreen( ApiImportResultDialog(result = result, onDismiss = { viewModel.dismissImportResult() }) } + // Delete blocked: plan still has open sell orders. User must cancel them first. + uiState.deleteBlockedOpenSells?.let { count -> + AlertDialog( + onDismissRequest = { viewModel.dismissDeleteBlockedDialog() }, + title = { Text(stringResource(R.string.plan_details_delete_blocked_title)) }, + text = { Text(stringResource(R.string.plan_details_delete_blocked_text, count)) }, + confirmButton = { + TextButton(onClick = { viewModel.dismissDeleteBlockedDialog() }) { + Text(stringResource(R.string.common_done)) + } + } + ) + } + Scaffold( snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, topBar = { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt index cbe0ea5..4b28d24 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt @@ -60,7 +60,13 @@ data class PlanDetailsUiState( val showImportDialog: Boolean = false, val importSinceMillis: Long? = null, /** Number of OTHER plans on the same connection. When > 0, import dialog shows a warning. */ - val otherPlansOnSameConnection: Int = 0 + val otherPlansOnSameConnection: Int = 0, + /** + * When non-null, indicates a plan-delete attempt was blocked because the plan still has + * the given number of open sell orders. UI should show a blocking dialog explaining the + * user must cancel them first. + */ + val deleteBlockedOpenSells: Int? = null ) @HiltViewModel @@ -326,6 +332,14 @@ class PlanDetailsViewModel @Inject constructor( fun deletePlan(onDeleted: () -> Unit) { viewModelScope.launch { try { + // Block delete when the plan still has open sell orders on the exchange. + // Without this guard, the user would lose the FK link to the order rows and + // any subsequent fill polling would silently fail. + val openSellsCount = transactionDao.observeOpenSellsForPlan(planId).first().size + if (openSellsCount > 0) { + _uiState.update { it.copy(deleteBlockedOpenSells = openSellsCount) } + return@launch + } database.withTransaction { transactionDao.deleteTransactionsByPlanId(planId) dcaPlanDao.deletePlanById(planId) @@ -338,6 +352,10 @@ class PlanDetailsViewModel @Inject constructor( } } + fun dismissDeleteBlockedDialog() { + _uiState.update { it.copy(deleteBlockedOpenSells = null) } + } + fun showImportDialog() { _uiState.update { it.copy(showImportDialog = true, importSinceMillis = null) } } diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 7aeb4df..4748ac2 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -323,6 +323,8 @@ Smazat všechny transakce %1$d transakcí smazáno + Vytvořit prodejní příkaz + Nelze smazat plán + Tento plán má %1$d otevřených sell orderů. Zruš je nejdřív, než plán smažeš. Upravit plán diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index fcf45de..0e22fe5 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -322,6 +322,8 @@ Delete All Transactions %1$d transactions deleted + Vytvorit prodejni prikaz + Cannot delete plan + This plan has %1$d open sell order(s). Cancel them first before deleting the plan. Edit Plan From 2d2350e6641e44b53e7f68e67eb51f38232e2740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 24 Apr 2026 08:16:40 +0200 Subject: [PATCH 27/75] feat(sell): pull-to-refresh on plan-detail (Task 32) Material3 PullToRefreshBox wraps the LazyColumn. Pull triggers ResolvePendingTransactionsUseCase to poll exchange for fill status of any pending/partial orders for the plan. Underlying Flow collectors push updates back to UI. Co-Authored-By: Claude Opus 4.7 --- .../screens/plans/PlanDetailsScreen.kt | 11 +++++++- .../screens/plans/PlanDetailsViewModel.kt | 25 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt index c95fe9c..c6e4553 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.TrendingDown import androidx.compose.material.icons.automirrored.filled.TrendingUp import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -56,6 +57,7 @@ fun PlanDetailsScreen( val sellUiVisible by viewModel.sellUiVisible.collectAsStateWithLifecycle() val planPnL by viewModel.planPnL.collectAsStateWithLifecycle() val openSells by viewModel.openSells.collectAsStateWithLifecycle() + val refreshing by viewModel.refreshing.collectAsStateWithLifecycle() val context = LocalContext.current val coroutineScope = rememberCoroutineScope() var showDeleteDialog by rememberSaveable { mutableStateOf(false) } @@ -260,10 +262,16 @@ fun PlanDetailsScreen( uiState.plan != null -> { val plan = uiState.plan!! - LazyColumn( + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { viewModel.refresh() }, modifier = Modifier .fillMaxSize() .padding(paddingValues) + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { @@ -961,6 +969,7 @@ fun PlanDetailsScreen( item { Spacer(modifier = Modifier.height(32.dp)) } } + } // PullToRefreshBox } } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt index 4b28d24..133270a 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt @@ -17,6 +17,7 @@ import com.accbot.dca.domain.usecase.ApiImportResultState import com.accbot.dca.domain.usecase.CalculatePlanPnLUseCase import com.accbot.dca.domain.usecase.CancelSellOrderUseCase import com.accbot.dca.domain.usecase.ImportTradeHistoryUseCase +import com.accbot.dca.domain.usecase.ResolvePendingTransactionsUseCase import com.accbot.dca.exchange.ExchangeApiFactory import com.accbot.dca.presentation.utils.NumberFormatters import com.accbot.dca.R @@ -81,7 +82,8 @@ class PlanDetailsViewModel @Inject constructor( private val userPreferences: UserPreferences, private val importTradeHistoryUseCase: ImportTradeHistoryUseCase, private val calculatePlanPnLUseCase: CalculatePlanPnLUseCase, - private val cancelSellOrderUseCase: CancelSellOrderUseCase + private val cancelSellOrderUseCase: CancelSellOrderUseCase, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase ) : ViewModel() { private val _uiState = MutableStateFlow(PlanDetailsUiState()) @@ -99,6 +101,9 @@ class PlanDetailsViewModel @Inject constructor( private val _snackbar = MutableSharedFlow(extraBufferCapacity = 4) val snackbar: SharedFlow = _snackbar.asSharedFlow() + private val _refreshing = MutableStateFlow(false) + val refreshing: StateFlow = _refreshing.asStateFlow() + private var planId: Long = 0 private var transactionCollectionJob: Job? = null private var openSellsJob: Job? = null @@ -250,6 +255,24 @@ class PlanDetailsViewModel @Inject constructor( } } + /** + * Pull-to-refresh on plan-detail. Polls the exchange for fill status of any + * pending sell (or pending buy) orders for this plan; the underlying Flow + * collectors then push the updated rows back to the UI automatically. + */ + fun refresh() { + viewModelScope.launch { + _refreshing.value = true + try { + resolvePendingTransactionsUseCase() + } catch (e: Exception) { + Log.w(TAG, "Pull-to-refresh failed", e) + } finally { + _refreshing.value = false + } + } + } + fun cancelSell(txId: Long) { viewModelScope.launch { val result = cancelSellOrderUseCase(txId) From 49acde02bf9929cbd155b108b920fb1ee468fc1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 26 Apr 2026 09:17:36 +0200 Subject: [PATCH 28/75] fix(sell): match Room schema in v20->v21 migration Three Room schema-validator mismatches caused IllegalStateException at app start: 1. Custom index 'idx_tx_plan_side_status' was created by migration but not declared in entity -> Room flagged as extra index. Added Index(value = ["planId","side","status"]) to TransactionEntity and renamed migration index to Room convention (index_transactions_planId_side_status). 2. Migration's `DEFAULT NULL` on nullable columns mismatched entity's defaultValue=undefined. Removed `DEFAULT NULL` clauses for limitPrice, requestedCryptoAmount, targetProfitAmount. 3. Migration's `DEFAULT 0` / `DEFAULT 'BUY'` on NOT NULL columns mismatched entity (no @ColumnInfo). Added @ColumnInfo(defaultValue=...) to allowSells and side fields. Co-Authored-By: Claude Opus 4.7 --- .../java/com/accbot/dca/data/local/DcaDatabase.kt | 13 +++++++++---- .../main/java/com/accbot/dca/data/local/Entities.kt | 6 +++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt index 80d2701..3186da7 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt @@ -376,14 +376,19 @@ abstract class DcaDatabase : RoomDatabase() { // Migration from version 20 to 21: Add sell extension fields to dca_plans and transactions. // Enables opt-in limit sell orders, P&L tracking, and optional profit targets. + // + // Defaults: allowSells/side use ColumnInfo defaultValue (must match SQL DEFAULT exactly). + // Nullable columns intentionally OMIT `DEFAULT NULL` - Room schema validator treats + // nullable-with-no-Kotlin-default as "no SQL default", and explicit DEFAULT NULL + // would cause a schema mismatch. private val MIGRATION_20_21 = object : Migration(20, 21) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE dca_plans ADD COLUMN allowSells INTEGER NOT NULL DEFAULT 0") - database.execSQL("ALTER TABLE dca_plans ADD COLUMN targetProfitAmount TEXT DEFAULT NULL") + database.execSQL("ALTER TABLE dca_plans ADD COLUMN targetProfitAmount TEXT") database.execSQL("ALTER TABLE transactions ADD COLUMN side TEXT NOT NULL DEFAULT 'BUY'") - database.execSQL("ALTER TABLE transactions ADD COLUMN limitPrice TEXT DEFAULT NULL") - database.execSQL("ALTER TABLE transactions ADD COLUMN requestedCryptoAmount TEXT DEFAULT NULL") - database.execSQL("CREATE INDEX IF NOT EXISTS idx_tx_plan_side_status ON transactions(planId, side, status)") + database.execSQL("ALTER TABLE transactions ADD COLUMN limitPrice TEXT") + database.execSQL("ALTER TABLE transactions ADD COLUMN requestedCryptoAmount TEXT") + database.execSQL("CREATE INDEX IF NOT EXISTS index_transactions_planId_side_status ON transactions(planId, side, status)") } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt index 3b30016..5bffabb 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt @@ -1,6 +1,7 @@ package com.accbot.dca.data.local import android.util.Log +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @@ -198,6 +199,7 @@ data class DcaPlanEntity( * Opt-in per-plan toggle for sell extension. When true (and global trading is enabled), * plan-detail shows P&L card, open sell orders list, and sell wizard button. */ + @ColumnInfo(defaultValue = "0") val allowSells: Boolean = false, /** * Optional profit goal (in [fiat]). When set, plan-detail shows progress bar toward this. @@ -220,7 +222,8 @@ data class DcaPlanEntity( Index(value = ["executedAt"]), Index(value = ["planId", "status"]), Index(value = ["crypto", "fiat", "status"]), - Index(value = ["fiat", "status"]) + Index(value = ["fiat", "status"]), + Index(value = ["planId", "side", "status"]) ] ) @TypeConverters(Converters::class) @@ -251,6 +254,7 @@ data class TransactionEntity( /** * BUY for DCA purchases (default), SELL for limit sell orders placed via sell extension. */ + @ColumnInfo(defaultValue = "BUY") val side: TransactionSide = TransactionSide.BUY, /** * Requested limit price for SELL orders; null for market BUYs. From 36a40a8bf6e0bb764e1c2d5bf68dbaf62d97d618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 26 Apr 2026 09:22:13 +0200 Subject: [PATCH 29/75] fix(sell): namespace open-sells card key to avoid LazyColumn collision Dashboard portrait LazyColumn iterates both `openSellsByPlan` (Long planId keys) and `activePlans` (Long plan.id keys) in the same LazyColumn. When a plan has open sells AND is in active plans, the same Long key is used twice -> Compose crashes with IllegalArgumentException("Key X was already used"). Prefix the open-sells item key with "open-sells-" string to namespace it away from plan ids. Co-Authored-By: Claude Opus 4.7 --- .../java/com/accbot/dca/presentation/screens/DashboardScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt index 2f98e90..0e7e10b 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt @@ -417,7 +417,7 @@ fun DashboardScreen( // Open SELL orders, grouped per plan if (uiState.openSellsByPlan.isNotEmpty()) { - items(uiState.openSellsByPlan.entries.toList(), key = { it.key }) { entry -> + items(uiState.openSellsByPlan.entries.toList(), key = { "open-sells-${it.key}" }) { entry -> val plan = uiState.activePlans.firstOrNull { it.plan.id == entry.key }?.plan if (plan != null) { OpenSellsSummaryCard( From da549bdf36f13e3850e8e427c887332d59ebb943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 26 Apr 2026 10:51:38 +0200 Subject: [PATCH 30/75] refactor(portfolio): rename Portfolio to Pozice (Czech) / Positions (English) in user-facing strings --- accbot-android/app/src/main/res/values-cs/strings.xml | 8 ++++---- accbot-android/app/src/main/res/values/strings.xml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 4748ac2..ab9e717 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -37,7 +37,7 @@ Přehled - Portfolio + Pozice Oznámení Nastavení @@ -334,10 +334,10 @@ Uložit změny Zadejte adresu vaší %1$s peněženky - - Portfolio + + Pozice Transakce - Načítání portfolia… + Načítání pozic… Realizováno Čistý P&L diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 0e22fe5..75ef99f 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -39,7 +39,7 @@ Dashboard - Portfolio + Positions Notifications Settings @@ -333,10 +333,10 @@ Save Changes Enter your %1$s wallet address - - Portfolio + + Positions Transactions - Loading portfolio… + Loading positions… Realized Net P&L From ef0aa5834aeeb688985b772b01f10e8bebfc92f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 26 Apr 2026 10:57:03 +0200 Subject: [PATCH 31/75] feat(sell): horizontal lines for open sell orders on plan chart --- .../components/ChartComponents.kt | 30 ++++++- .../screens/portfolio/PortfolioScreen.kt | 23 +++++- .../screens/portfolio/PortfolioViewModel.kt | 82 ++++++++++++++++++- 3 files changed, 131 insertions(+), 4 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt index 7526948..91d9377 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt @@ -59,6 +59,8 @@ import java.time.format.DateTimeFormatter import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisGuidelineComponent import com.patrykandpatrick.vico.compose.cartesian.marker.rememberShowOnPress import com.patrykandpatrick.vico.compose.common.component.rememberShapeComponent +import com.patrykandpatrick.vico.compose.common.component.rememberLineComponent +import com.patrykandpatrick.vico.core.cartesian.decoration.HorizontalLine import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerController import com.patrykandpatrick.vico.core.common.shape.CorneredShape @@ -219,6 +221,13 @@ fun PortfolioLineChart( * on the chart timeline. Currently a stub – see [ChartTradeMarker] kdoc. */ @Suppress("UNUSED_PARAMETER") tradeMarkers: List = emptyList(), + /** + * Limit prices of currently open (PENDING / PARTIAL) sell orders for the + * displayed plan. Each value yields a horizontal line on the left (fiat) axis + * to give the user a visual reference for where their orders will fill. + * Empty for aggregate pages or plans with sells disabled. + */ + openSellLimitPrices: List = emptyList(), modifier: Modifier = Modifier ) { if (chartData.isEmpty()) return @@ -488,6 +497,24 @@ fun PortfolioLineChart( if (isEmpty()) add(hiddenLine) } + // Open-sell limit-price horizontal lines (per plan, on the left/fiat axis). + // Each value renders a thin red line so the user can visually compare their + // pending sell targets against the current portfolio value / crypto price. + val sellLineColor = MaterialTheme.colorScheme.error + val sellLineComponent = rememberLineComponent( + fill = fill(sellLineColor), + thickness = 1.dp + ) + val sellDecorations = remember(openSellLimitPrices, sellLineComponent) { + openSellLimitPrices.map { price -> + val v = price.toDouble() + HorizontalLine( + y = { v }, + line = sellLineComponent + ) + } + } + // Tap-to-inspect marker – scrub fires onScrub to update KPI cards, no tooltip text val indicatorComponent = rememberShapeComponent( fill = fill(chartAccentColor), @@ -582,7 +609,8 @@ fun PortfolioLineChart( } ), marker = marker, - markerController = CartesianMarkerController.rememberShowOnPress() + markerController = CartesianMarkerController.rememberShowOnPress(), + decorations = sellDecorations ), modelProducer = modelProducer, scrollState = rememberVicoScrollState(scrollEnabled = false), diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index f307848..e372163 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -68,9 +68,17 @@ fun PortfolioScreen( viewModel: PortfolioViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val openSellLimitPrices by viewModel.openSellLimitPrices.collectAsStateWithLifecycle() + val openSells by viewModel.openSells.collectAsStateWithLifecycle() val configuration = LocalConfiguration.current val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + // Snackbar host for cancel-order failures + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(Unit) { + viewModel.snackbar.collect { msg -> snackbarHostState.showSnackbar(msg) } + } + // Refresh portfolio data when returning to screen (e.g. after transaction import) val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { @@ -146,6 +154,9 @@ fun PortfolioScreen( visibleCryptoGroupLines = uiState.visibleCryptoGroupLines, zoomLevel = uiState.zoomLevel, onScrub = { idx -> scrubbedIndex = idx ?: -1 }, + // Show open-sell limit lines only on per-plan pages with sells enabled + openSellLimitPrices = if (uiState.currentPlanAllowsSells && + uiState.denominationMode == DenominationMode.FIAT) openSellLimitPrices else emptyList(), modifier = Modifier .fillMaxWidth() .weight(1f) @@ -284,7 +295,8 @@ fun PortfolioScreen( } } ) - } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } ) { paddingValues -> when { uiState.isLoading -> { @@ -313,6 +325,9 @@ fun PortfolioScreen( else -> { PortfolioContent( uiState = uiState, + openSellLimitPrices = openSellLimitPrices, + openSells = openSells, + onCancelSell = viewModel::cancelSell, onDrillDownYear = { viewModel.drillDownToYear(it) }, onDrillDownMonth = { year, month -> viewModel.drillDownToMonth(year, month) }, onZoomOut = { viewModel.zoomOut() }, @@ -336,6 +351,9 @@ fun PortfolioScreen( @Composable internal fun PortfolioContent( uiState: PortfolioUiState, + openSellLimitPrices: List = emptyList(), + openSells: List = emptyList(), + onCancelSell: (Long) -> Unit = {}, onDrillDownYear: (Int) -> Unit, onDrillDownMonth: (Int, Int) -> Unit, onZoomOut: () -> Unit, @@ -583,6 +601,9 @@ internal fun PortfolioContent( visibleCryptoGroupLines = uiState.visibleCryptoGroupLines, zoomLevel = uiState.zoomLevel, onScrub = { idx -> scrubbedIndex = idx ?: -1 }, + // Show open-sell limit lines only on per-plan pages with sells enabled + openSellLimitPrices = if (uiState.currentPlanAllowsSells && + uiState.denominationMode == DenominationMode.FIAT) openSellLimitPrices else emptyList(), modifier = Modifier.fillMaxWidth() ) } else if (chartData.size == 1) { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt index 449b82d..895e8c5 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt @@ -11,6 +11,9 @@ import com.accbot.dca.data.local.TransactionEntity import com.accbot.dca.data.local.UserPreferences // TransactionStatus filtering now done in DAO query import com.accbot.dca.domain.usecase.CalculateChartDataUseCase +import com.accbot.dca.domain.usecase.CancelSellOrderUseCase +import com.accbot.dca.domain.model.Transaction +import com.accbot.dca.data.local.toDomain import com.accbot.dca.domain.usecase.ChartDataPoint import com.accbot.dca.domain.usecase.ChartZoomLevel import com.accbot.dca.domain.usecase.SyncDailyPricesUseCase @@ -98,7 +101,13 @@ data class PortfolioUiState( * Null when current price is unavailable (matches the existing chart-loading * pattern where ROI fields are also null until prices arrive). */ - val netPnL: BigDecimal? = null + val netPnL: BigDecimal? = null, + /** + * True when the currently selected page is a [PairPage.Plan] AND the plan has + * `allowSells = true`. Drives visibility of the open-orders list and chart + * horizontal lines on the per-plan page. + */ + val currentPlanAllowsSells: Boolean = false ) @HiltViewModel @@ -108,7 +117,8 @@ class PortfolioViewModel @Inject constructor( private val dcaPlanDao: DcaPlanDao, private val syncDailyPricesUseCase: SyncDailyPricesUseCase, private val calculateChartDataUseCase: CalculateChartDataUseCase, - private val userPreferences: UserPreferences + private val userPreferences: UserPreferences, + private val cancelSellOrderUseCase: CancelSellOrderUseCase ) : ViewModel() { // Consumed once on first loadPortfolio() and then nulled out so a process-death @@ -127,6 +137,28 @@ class PortfolioViewModel @Inject constructor( private val _uiState = MutableStateFlow(PortfolioUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** + * Stream of open (PENDING / PARTIAL) sell-order transactions for the currently + * selected per-plan page. Empty when the page is Aggregate, the plan has + * allowSells=false, or no open sells exist. Drives both the chart's horizontal + * limit-price lines (Task B) and the collapsible open-orders list (Task C). + */ + private val _openSells = MutableStateFlow>(emptyList()) + val openSells: StateFlow> = _openSells.asStateFlow() + + /** + * Convenience derived flow exposing only the sorted limit prices for the + * chart's horizontal lines. + */ + val openSellLimitPrices: StateFlow> = openSells + .map { txs -> txs.mapNotNull { it.limitPrice } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + private val _snackbar = MutableSharedFlow(extraBufferCapacity = 4) + val snackbar: SharedFlow = _snackbar.asSharedFlow() + + private var openSellsJob: Job? = null + private var completedTransactions: List = emptyList() /** * Cached plan list used by [loadChartData] to build aggregate per-plan lines. @@ -227,6 +259,7 @@ class PortfolioViewModel @Inject constructor( } updateNavigationState() + refreshOpenSellsForCurrentPage() syncPricesAndLoadChart() lastLoadedAt = System.currentTimeMillis() } catch (e: CancellationException) { @@ -276,6 +309,7 @@ class PortfolioViewModel @Inject constructor( ) } updateNavigationState() + refreshOpenSellsForCurrentPage() lastTransactionsFetchedAt = System.currentTimeMillis() } catch (e: CancellationException) { throw e @@ -412,6 +446,50 @@ class PortfolioViewModel @Inject constructor( } updateNavigationState() loadChartData() + refreshOpenSellsForCurrentPage() + } + + /** + * (Re)subscribe to the open-sells Flow for the currently selected page. + * Cancels any prior subscription so we never have two collectors competing + * to push into [_openSells]. Aggregate pages and plans without `allowSells` + * yield an empty list. + */ + private fun refreshOpenSellsForCurrentPage() { + openSellsJob?.cancel() + val page = _uiState.value.pages.getOrNull(_uiState.value.selectedPageIndex) + if (page !is PairPage.Plan) { + _openSells.value = emptyList() + _uiState.update { it.copy(currentPlanAllowsSells = false) } + return + } + val planEntity = cachedDbPlans.firstOrNull { it.id == page.planId } + val allowSells = planEntity?.allowSells == true + _uiState.update { it.copy(currentPlanAllowsSells = allowSells) } + if (!allowSells) { + _openSells.value = emptyList() + return + } + openSellsJob = viewModelScope.launch { + transactionDao.observeOpenSellsForPlan(page.planId).collect { entities -> + _openSells.value = entities.map { it.toDomain() } + } + } + } + + /** + * Cancel an open limit-sell order for the currently visible plan. On failure + * a localized message is pushed to [snackbar] for the screen to display. + */ + fun cancelSell(txId: Long) { + viewModelScope.launch { + val result = cancelSellOrderUseCase(txId) + if (result.isFailure) { + _snackbar.emit( + "Zruseni orderu selhalo: ${result.exceptionOrNull()?.message ?: "neznama chyba"}" + ) + } + } } fun toggleDenomination() { From 12ea0244f3fcb8a7b2ab8753a10880ab9bcdb077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 26 Apr 2026 10:59:53 +0200 Subject: [PATCH 32/75] feat(sell): collapsible open orders section on Pozice plan page --- .../screens/plans/components/OpenSellsList.kt | 7 +- .../screens/portfolio/PortfolioScreen.kt | 10 ++ .../components/OpenSellsCollapsibleSection.kt | 101 ++++++++++++++++++ .../app/src/main/res/values-cs/strings.xml | 1 + .../app/src/main/res/values/strings.xml | 1 + 5 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/components/OpenSellsCollapsibleSection.kt diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/OpenSellsList.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/OpenSellsList.kt index 31c0a65..4890c14 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/OpenSellsList.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/OpenSellsList.kt @@ -72,8 +72,13 @@ fun OpenSellsList( } } +/** + * Single row used by [OpenSellsList]. Exposed as `internal` so other screens + * (e.g. the Pozice tab's collapsible section) can render the same row layout + * with its built-in cancel-confirmation dialog without re-implementing it. + */ @Composable -private fun OpenSellRow( +internal fun OpenSellRow( tx: Transaction, onCancelClick: (Long) -> Unit ) { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index e372163..945dd3b 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -652,6 +652,16 @@ internal fun PortfolioContent( } } + // Open sell-orders collapsible section (only on per-plan pages with sells enabled) + if (uiState.currentPlanAllowsSells && openSells.isNotEmpty()) { + item(key = "portfolio-open-sells-section") { + com.accbot.dca.presentation.screens.portfolio.components.OpenSellsCollapsibleSection( + openSells = openSells, + onCancelClick = onCancelSell + ) + } + } + // Zoom header (back arrow + prev/next navigation) item { Crossfade(targetState = uiState.zoomLevel, label = "zoom") { zoom -> diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/components/OpenSellsCollapsibleSection.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/components/OpenSellsCollapsibleSection.kt new file mode 100644 index 0000000..fd052c4 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/components/OpenSellsCollapsibleSection.kt @@ -0,0 +1,101 @@ +package com.accbot.dca.presentation.screens.portfolio.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.accbot.dca.R +import com.accbot.dca.domain.model.Transaction +import com.accbot.dca.presentation.screens.plans.components.OpenSellRow + +/** + * Collapsible section showing open (PENDING / PARTIAL) sell orders for the + * currently selected plan on the Pozice (Portfolio) screen. Header is always + * visible; the order list is hidden by default and toggled by tapping the + * header. Hidden entirely when there are no open orders for the plan. + * + * Reuses [OpenSellRow] from the plan-detail screen so the cancel-confirmation + * dialog is shared between the two surfaces. + */ +@Composable +fun OpenSellsCollapsibleSection( + openSells: List, + onCancelClick: (Long) -> Unit, + modifier: Modifier = Modifier +) { + if (openSells.isEmpty()) return + + // Persist expand state across configuration changes (rotation) but reset on + // process death - matches the "transient UI" feel. + var expanded by rememberSaveable { mutableStateOf(false) } + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Tappable header + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource( + R.string.portfolio_open_sells_section_title, + openSells.size + ), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + AnimatedVisibility(visible = expanded) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + ) { + HorizontalDivider() + openSells.forEach { tx -> + OpenSellRow(tx = tx, onCancelClick = onCancelClick) + } + } + } + } + } +} diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index ab9e717..5dd36d7 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -340,6 +340,7 @@ Načítání pozic… Realizováno Čistý P&L + Otevřené sell ordery (%1$d) Všechny páry diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 75ef99f..b7ae2a4 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -339,6 +339,7 @@ Loading positions… Realized Net P&L + Open sell orders (%1$d) All Pairs From b519b507bf37e5038941fbf0458c36a605a090ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 26 Apr 2026 11:22:49 +0200 Subject: [PATCH 33/75] feat(sell): dashed limit-order lines, y-axis range, legend entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polish open-sell horizontal lines on the Pozice per-plan chart: - Dashed style (1.5dp red, 6/4 dash) instead of solid 1dp so they read clearly as targets rather than data series. - Left-axis range provider that expands the auto Y range to include the min/max of currently open sell limit prices (with 5% headroom), so lines far above current price are no longer clipped off-chart. - New `LimitOrderLegendItem` shown below the chart legend whenever the dashed lines render (currentPlanAllowsSells + FIAT + non-empty open sells), with localized "Limit sell" / "Limitní prodej" label and a small dashed mini-line as the swatch. --- .../components/ChartComponents.kt | 77 ++++++++++++++++++- .../screens/portfolio/PortfolioScreen.kt | 28 +++++++ .../app/src/main/res/values-cs/strings.xml | 1 + .../app/src/main/res/values/strings.xml | 1 + 4 files changed, 103 insertions(+), 4 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt index 91d9377..3be2d0a 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt @@ -60,9 +60,13 @@ import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisGuidelineCom import com.patrykandpatrick.vico.compose.cartesian.marker.rememberShowOnPress import com.patrykandpatrick.vico.compose.common.component.rememberShapeComponent import com.patrykandpatrick.vico.compose.common.component.rememberLineComponent +import com.patrykandpatrick.vico.core.cartesian.data.CartesianLayerRangeProvider import com.patrykandpatrick.vico.core.cartesian.decoration.HorizontalLine import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerController +import com.patrykandpatrick.vico.core.common.data.ExtraStore import com.patrykandpatrick.vico.core.common.shape.CorneredShape +import com.patrykandpatrick.vico.core.common.shape.DashedShape +import com.patrykandpatrick.vico.core.common.shape.Shape private val chartAccentColor = Primary private val costBasisColor = Color(0xFF888888) @@ -155,6 +159,43 @@ fun InteractiveChartLegend( } } +/** + * Static (non-interactive) legend entry describing the dashed red horizontal lines + * that mark open sell-order limit prices on the per-plan chart. Shown only when + * the chart actually renders such lines (i.e. plan allows sells, FIAT mode, and + * at least one open sell exists). Mirrors the dashed look used on the chart with + * a tiny dashed mini-line as the colour swatch. + */ +@Composable +fun LimitOrderLegendItem( + label: String, + modifier: Modifier = Modifier, +) { + val color = MaterialTheme.colorScheme.error + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.padding(4.dp) + ) { + // Mini dashed line: 3 short segments to evoke the chart's dash pattern. + Row(verticalAlignment = Alignment.CenterVertically) { + repeat(3) { i -> + if (i > 0) Spacer(Modifier.width(2.dp)) + Box( + Modifier + .size(width = 4.dp, height = 2.dp) + .background(color) + ) + } + } + Spacer(Modifier.width(6.dp)) + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + @Composable private fun LegendItem(color: Color, label: String, enabled: Boolean = true, onClick: () -> Unit = {}) { Row( @@ -498,12 +539,21 @@ fun PortfolioLineChart( } // Open-sell limit-price horizontal lines (per plan, on the left/fiat axis). - // Each value renders a thin red line so the user can visually compare their - // pending sell targets against the current portfolio value / crypto price. + // Each value renders a thin dashed red line so the user can visually compare + // their pending sell targets against the current portfolio value / crypto price. val sellLineColor = MaterialTheme.colorScheme.error + val dashedSellShape = remember { + DashedShape( + shape = Shape.Rectangle, + dashLengthDp = 6f, + gapLengthDp = 4f, + fitStrategy = DashedShape.FitStrategy.Resize + ) + } val sellLineComponent = rememberLineComponent( fill = fill(sellLineColor), - thickness = 1.dp + thickness = 1.5.dp, + shape = dashedSellShape ) val sellDecorations = remember(openSellLimitPrices, sellLineComponent) { openSellLimitPrices.map { price -> @@ -515,6 +565,24 @@ fun PortfolioLineChart( } } + // Y-axis range provider: when there are open-sell limit lines, expand the + // auto-calculated Y range (left/fiat axis) so all limit prices remain visible + // even when they sit far above/below the actual portfolio/price series. + val leftRangeProvider = remember(openSellLimitPrices) { + if (openSellLimitPrices.isEmpty()) { + CartesianLayerRangeProvider.auto() + } else { + object : CartesianLayerRangeProvider { + private val limitMax = openSellLimitPrices.maxOf { it.toDouble() } + private val limitMin = openSellLimitPrices.minOf { it.toDouble() } + override fun getMaxY(minY: Double, maxY: Double, extraStore: ExtraStore): Double = + maxOf(maxY, limitMax * 1.05) + override fun getMinY(minY: Double, maxY: Double, extraStore: ExtraStore): Double = + minOf(minY, limitMin * 0.95) + } + } + } + // Tap-to-inspect marker – scrub fires onScrub to update KPI cards, no tooltip text val indicatorComponent = rememberShapeComponent( fill = fill(chartAccentColor), @@ -576,7 +644,8 @@ fun PortfolioLineChart( CartesianChartHost( chart = rememberCartesianChart( rememberLineCartesianLayer( - lineProvider = LineCartesianLayer.LineProvider.series(leftLines) + lineProvider = LineCartesianLayer.LineProvider.series(leftLines), + rangeProvider = leftRangeProvider ), rememberLineCartesianLayer( lineProvider = LineCartesianLayer.LineProvider.series(rightLines), diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index 945dd3b..ed57650 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -182,6 +182,20 @@ fun PortfolioScreen( onToggleAdvanced = { viewModel.toggleAdvancedLegendExpanded() } ) } + // Limit-sell legend entry (landscape) + if (uiState.currentPlanAllowsSells && + uiState.denominationMode == DenominationMode.FIAT && + openSellLimitPrices.isNotEmpty() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + LimitOrderLegendItem( + label = stringResource(R.string.chart_legend_limit_sell) + ) + } + } // Zoom header + drill-down chips Column( @@ -649,6 +663,20 @@ internal fun PortfolioContent( onToggleAdvanced = onToggleAdvancedLegend ) } + // Limit-sell legend entry: shown only when matching dashed lines render on the chart + if (uiState.currentPlanAllowsSells && + uiState.denominationMode == DenominationMode.FIAT && + openSellLimitPrices.isNotEmpty() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + LimitOrderLegendItem( + label = stringResource(R.string.chart_legend_limit_sell) + ) + } + } } } diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 5dd36d7..c8e3086 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -375,6 +375,7 @@ Akum. %1$s Prům. nákupní cena Prozkoumat historii: + Limitní prodej Burzy diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index b7ae2a4..7f3148e 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -374,6 +374,7 @@ Accum. %1$s Avg Buy Price Explore history: + Limit sell Exchanges From d9e239a887ebf0f024bf80eeae0c940fa8efabc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 26 Apr 2026 11:37:15 +0200 Subject: [PATCH 34/75] feat(sell): toggleable limit-order legend on Pozice chart Co-Authored-By: Claude Opus 4.6 --- .../presentation/components/ChartComponents.kt | 15 +++++++++++---- .../screens/portfolio/PortfolioScreen.kt | 16 ++++++++++++---- .../screens/portfolio/PortfolioViewModel.kt | 5 +++++ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt index 3be2d0a..c5672d6 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt @@ -170,11 +170,16 @@ fun InteractiveChartLegend( fun LimitOrderLegendItem( label: String, modifier: Modifier = Modifier, + enabled: Boolean = true, + onClick: () -> Unit = {}, ) { - val color = MaterialTheme.colorScheme.error + val baseColor = MaterialTheme.colorScheme.error + val swatchColor = if (enabled) baseColor else baseColor.copy(alpha = 0.3f) Row( verticalAlignment = Alignment.CenterVertically, - modifier = modifier.padding(4.dp) + modifier = modifier + .clickable(role = Role.Button, onClick = onClick) + .padding(4.dp) ) { // Mini dashed line: 3 short segments to evoke the chart's dash pattern. Row(verticalAlignment = Alignment.CenterVertically) { @@ -183,7 +188,7 @@ fun LimitOrderLegendItem( Box( Modifier .size(width = 4.dp, height = 2.dp) - .background(color) + .background(swatchColor) ) } } @@ -191,7 +196,9 @@ fun LimitOrderLegendItem( Text( label, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + textDecoration = if (enabled) null else TextDecoration.LineThrough ) } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index ed57650..a500f27 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -156,7 +156,8 @@ fun PortfolioScreen( onScrub = { idx -> scrubbedIndex = idx ?: -1 }, // Show open-sell limit lines only on per-plan pages with sells enabled openSellLimitPrices = if (uiState.currentPlanAllowsSells && - uiState.denominationMode == DenominationMode.FIAT) openSellLimitPrices else emptyList(), + uiState.denominationMode == DenominationMode.FIAT && + uiState.limitLinesVisible) openSellLimitPrices else emptyList(), modifier = Modifier .fillMaxWidth() .weight(1f) @@ -192,7 +193,9 @@ fun PortfolioScreen( horizontalArrangement = Arrangement.Center ) { LimitOrderLegendItem( - label = stringResource(R.string.chart_legend_limit_sell) + label = stringResource(R.string.chart_legend_limit_sell), + enabled = uiState.limitLinesVisible, + onClick = { viewModel.toggleLimitLinesVisibility() } ) } } @@ -352,6 +355,7 @@ fun PortfolioScreen( onTogglePlanLineVisibility = { id, type -> viewModel.togglePlanLineVisibility(id, type) }, onToggleCryptoGroupLineVisibility = { crypto, type -> viewModel.toggleCryptoGroupLineVisibility(crypto, type) }, onToggleAdvancedLegend = { viewModel.toggleAdvancedLegendExpanded() }, + onToggleLimitLinesVisibility = { viewModel.toggleLimitLinesVisibility() }, onRefresh = { viewModel.syncPricesAndLoadChart() }, onChartTouching = onChartTouching, modifier = Modifier.padding(paddingValues) @@ -378,6 +382,7 @@ internal fun PortfolioContent( onTogglePlanLineVisibility: (Long, PlanLineType) -> Unit, onToggleCryptoGroupLineVisibility: (String, CryptoGroupLineType) -> Unit, onToggleAdvancedLegend: () -> Unit, + onToggleLimitLinesVisibility: () -> Unit = {}, onRefresh: () -> Unit, onChartTouching: (Boolean) -> Unit = {}, modifier: Modifier = Modifier @@ -617,7 +622,8 @@ internal fun PortfolioContent( onScrub = { idx -> scrubbedIndex = idx ?: -1 }, // Show open-sell limit lines only on per-plan pages with sells enabled openSellLimitPrices = if (uiState.currentPlanAllowsSells && - uiState.denominationMode == DenominationMode.FIAT) openSellLimitPrices else emptyList(), + uiState.denominationMode == DenominationMode.FIAT && + uiState.limitLinesVisible) openSellLimitPrices else emptyList(), modifier = Modifier.fillMaxWidth() ) } else if (chartData.size == 1) { @@ -673,7 +679,9 @@ internal fun PortfolioContent( horizontalArrangement = Arrangement.Center ) { LimitOrderLegendItem( - label = stringResource(R.string.chart_legend_limit_sell) + label = stringResource(R.string.chart_legend_limit_sell), + enabled = uiState.limitLinesVisible, + onClick = onToggleLimitLinesVisibility ) } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt index 895e8c5..be5eae5 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt @@ -74,6 +74,7 @@ data class PortfolioUiState( val currentPairFiat: String? = null, val totalTransactions: Int = 0, val visibleSeries: Set = setOf(0, 1), + val limitLinesVisible: Boolean = true, val scrubbedIndex: Int? = null, val planLines: List = emptyList(), val visiblePlanLines: Set> = emptySet(), @@ -514,6 +515,10 @@ class PortfolioViewModel @Inject constructor( } } + fun toggleLimitLinesVisibility() { + _uiState.update { state -> state.copy(limitLinesVisible = !state.limitLinesVisible) } + } + fun togglePlanLineVisibility(planId: Long, type: PlanLineType) { _uiState.update { state -> val key = planId to type From a90aeb50c09cd4b5769fdfe660284d62aea80095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 1 May 2026 13:13:14 +0200 Subject: [PATCH 35/75] feat(sell): inline open-sells badge in plan cards, unified settings card, sell button on Pozice Move open-sell count from standalone dashboard cards into DcaPlanCard badges. Merge sell-polling toggle + frequency into a single animated SellPollingCard. Add "New sell order" button on Pozice screen opening SellWizardBottomSheet. Wrap chart in key(openSellLimitPrices) to force recompose on limit-line changes. Default limit lines to hidden. Co-Authored-By: Claude Opus 4.6 --- .../components/ChartComponents.kt | 89 ++++---- .../presentation/screens/DashboardScreen.kt | 116 +++-------- .../presentation/screens/SettingsScreen.kt | 191 ++++++++++-------- .../screens/portfolio/PortfolioScreen.kt | 52 ++++- .../screens/portfolio/PortfolioViewModel.kt | 2 +- 5 files changed, 224 insertions(+), 226 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt index c5672d6..bbda776 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType @@ -648,50 +649,52 @@ fun PortfolioLineChart( } } ) { - CartesianChartHost( - chart = rememberCartesianChart( - rememberLineCartesianLayer( - lineProvider = LineCartesianLayer.LineProvider.series(leftLines), - rangeProvider = leftRangeProvider - ), - rememberLineCartesianLayer( - lineProvider = LineCartesianLayer.LineProvider.series(rightLines), - verticalAxisPosition = Axis.Position.Vertical.End - ), - startAxis = VerticalAxis.rememberStart( - label = axisLabelComponent, - title = unitSuffix, - titleComponent = axisTitleComponent, - itemPlacer = remember { VerticalAxis.ItemPlacer.count(count = { 5 }) }, - valueFormatter = { _, value, _ -> - val bd = BigDecimal.valueOf(value) - when { - value >= 1 -> NumberFormatters.compactFiat(bd) - else -> NumberFormatters.cryptoCompact(bd) + key(openSellLimitPrices) { + CartesianChartHost( + chart = rememberCartesianChart( + rememberLineCartesianLayer( + lineProvider = LineCartesianLayer.LineProvider.series(leftLines), + rangeProvider = leftRangeProvider + ), + rememberLineCartesianLayer( + lineProvider = LineCartesianLayer.LineProvider.series(rightLines), + verticalAxisPosition = Axis.Position.Vertical.End + ), + startAxis = VerticalAxis.rememberStart( + label = axisLabelComponent, + title = unitSuffix, + titleComponent = axisTitleComponent, + itemPlacer = remember { VerticalAxis.ItemPlacer.count(count = { 5 }) }, + valueFormatter = { _, value, _ -> + val bd = BigDecimal.valueOf(value) + when { + value >= 1 -> NumberFormatters.compactFiat(bd) + else -> NumberFormatters.cryptoCompact(bd) + } } - } - ), - endAxis = if (hasRightAxis) endAxisComponent else null, - bottomAxis = HorizontalAxis.rememberBottom( - label = axisLabelComponent, - valueFormatter = { _, value, _ -> - val index = value.toInt().coerceIn(0, xLabels.size - 1) - xLabels.getOrElse(index) { "" } - }, - itemPlacer = remember(chartData.size, xAxisSpacing) { - HorizontalAxis.ItemPlacer.aligned( - spacing = { xAxisSpacing } - ) - } + ), + endAxis = if (hasRightAxis) endAxisComponent else null, + bottomAxis = HorizontalAxis.rememberBottom( + label = axisLabelComponent, + valueFormatter = { _, value, _ -> + val index = value.toInt().coerceIn(0, xLabels.size - 1) + xLabels.getOrElse(index) { "" } + }, + itemPlacer = remember(chartData.size, xAxisSpacing) { + HorizontalAxis.ItemPlacer.aligned( + spacing = { xAxisSpacing } + ) + } + ), + marker = marker, + markerController = CartesianMarkerController.rememberShowOnPress(), + decorations = sellDecorations ), - marker = marker, - markerController = CartesianMarkerController.rememberShowOnPress(), - decorations = sellDecorations - ), - modelProducer = modelProducer, - scrollState = rememberVicoScrollState(scrollEnabled = false), - zoomState = rememberVicoZoomState(zoomEnabled = false, initialZoom = remember { Zoom.Content }), - modifier = Modifier.fillMaxSize() - ) + modelProducer = modelProducer, + scrollState = rememberVicoScrollState(scrollEnabled = false), + zoomState = rememberVicoZoomState(zoomEnabled = false, initialZoom = remember { Zoom.Content }), + modifier = Modifier.fillMaxSize() + ) + } } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt index 0e7e10b..0c9495d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt @@ -270,20 +270,6 @@ fun DashboardScreen( compact = true ) - if (uiState.openSellsByPlan.isNotEmpty()) { - uiState.openSellsByPlan.forEach { (planId, sells) -> - val plan = uiState.activePlans.firstOrNull { it.plan.id == planId }?.plan - if (plan != null) { - OpenSellsSummaryCard( - planName = plan.name.ifBlank { "${plan.crypto}/${plan.fiat}" }, - fiat = plan.fiat, - sells = sells, - onClick = { onNavigateToPlanDetails?.invoke(planId) } - ) - } - } - } - if (uiState.showMarketPulse && (uiState.fearGreedData != null || uiState.athDataByCrypto.isNotEmpty())) { MarketPulseCard( fearGreedData = uiState.fearGreedData, @@ -340,6 +326,7 @@ fun DashboardScreen( onToggle = { viewModel.togglePlan(planId) }, onClick = { onNavigateToPlanDetails?.invoke(planId) }, currentTime = currentTime, + openSellCount = uiState.openSellsByPlan[planId]?.size ?: 0, isDragging = landscapeDragState.isDragging(planId), dragOffset = if (landscapeDragState.isDragging(planId)) landscapeDragState.dragOffset else 0f, onDragStart = { heightPx -> landscapeDragState.startDrag(planId, heightPx) }, @@ -415,21 +402,6 @@ fun DashboardScreen( ) } - // Open SELL orders, grouped per plan - if (uiState.openSellsByPlan.isNotEmpty()) { - items(uiState.openSellsByPlan.entries.toList(), key = { "open-sells-${it.key}" }) { entry -> - val plan = uiState.activePlans.firstOrNull { it.plan.id == entry.key }?.plan - if (plan != null) { - OpenSellsSummaryCard( - planName = plan.name.ifBlank { "${plan.crypto}/${plan.fiat}" }, - fiat = plan.fiat, - sells = entry.value, - onClick = { onNavigateToPlanDetails?.invoke(plan.id) } - ) - } - } - } - // Market Pulse if (uiState.showMarketPulse && (uiState.fearGreedData != null || uiState.athDataByCrypto.isNotEmpty())) { item { @@ -474,6 +446,7 @@ fun DashboardScreen( onToggle = { viewModel.togglePlan(planId) }, onClick = { onNavigateToPlanDetails?.invoke(planId) }, currentTime = currentTime, + openSellCount = uiState.openSellsByPlan[planId]?.size ?: 0, isDragging = portraitDragState.isDragging(planId), dragOffset = if (portraitDragState.isDragging(planId)) portraitDragState.dragOffset else 0f, onDragStart = { heightPx -> portraitDragState.startDrag(planId, heightPx) }, @@ -1067,6 +1040,7 @@ internal fun DcaPlanCard( onToggle: () -> Unit, onClick: (() -> Unit)? = null, currentTime: Long = System.currentTimeMillis(), + openSellCount: Int = 0, isDragging: Boolean = false, dragOffset: Float = 0f, onDragStart: ((heightPx: Int) -> Unit)? = null, @@ -1347,6 +1321,28 @@ internal fun DcaPlanCard( fontWeight = FontWeight.Medium ) } + if (openSellCount > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = Icons.Default.TrendingDown, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.error + ) + Text( + text = stringResource( + R.string.dashboard_plan_open_sells, + openSellCount + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Medium + ) + } + } } } Column( @@ -1921,68 +1917,6 @@ private fun localizedFearGreedClass(value: Int): String { } } -/** - * Summary card surfacing all open SELL orders for a single plan. Shown on the - * dashboard between Holdings and Market Pulse. Tapping deep-links to the plan - * detail screen where the user can cancel or inspect each order. - */ -@Composable -internal fun OpenSellsSummaryCard( - planName: String, - fiat: String, - sells: List, - onClick: () -> Unit -) { - if (sells.isEmpty()) return - Card( - modifier = Modifier - .fillMaxWidth() - .clickable(role = Role.Button, onClick = onClick), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource( - R.string.dashboard_open_sells_title, - planName, - sells.size - ), - fontWeight = FontWeight.SemiBold, - style = MaterialTheme.typography.titleSmall - ) - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp) - ) - } - // First sell preview (most recent due to ORDER BY executedAt DESC in DAO). - sells.firstOrNull()?.let { tx -> - Spacer(modifier = Modifier.height(4.dp)) - val priceText = tx.limitPrice?.let { NumberFormatters.fiat(it) } ?: "-" - val amount = tx.requestedCryptoAmount ?: tx.cryptoAmount - Text( - text = "${NumberFormatters.crypto(amount)} ${tx.crypto} @ $priceText $fiat", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} - private val gaugeColors = listOf( Color(0xFFE53935), Color(0xFFFF9800), diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsScreen.kt index 33edb5f..3417b4e 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsScreen.kt @@ -44,7 +44,9 @@ import com.accbot.dca.data.local.AppTheme import com.accbot.dca.domain.model.DcaFrequency import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.WithdrawalThreshold +import com.accbot.dca.domain.util.CronUtils import com.accbot.dca.presentation.components.FrequencyDropdown +import com.accbot.dca.presentation.components.ScheduleBuilder import java.math.BigDecimal import com.accbot.dca.presentation.changelog.ChangelogData import com.accbot.dca.presentation.components.AccBotTopAppBar @@ -713,8 +715,10 @@ fun SettingsScreen( if (uiState.tradingEnabled) { item { - SellPollingToggleCard( + SellPollingCard( isEnabled = uiState.periodicSellPollingEnabled, + frequency = uiState.sellPollingFrequency, + cronExpression = uiState.sellPollingCronExpression, onToggle = { enabled -> viewModel.setPeriodicSellPolling( enabled = enabled, @@ -722,69 +726,24 @@ fun SettingsScreen( cron = uiState.sellPollingCronExpression, scheduleConfig = null ) - } - ) - } - - if (uiState.periodicSellPollingEnabled) { - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface + }, + onFrequencySelected = { newFreq -> + viewModel.setPeriodicSellPolling( + enabled = true, + frequency = newFreq, + cron = null, + scheduleConfig = null + ) + }, + onCronExpressionChange = { newCron -> + viewModel.setPeriodicSellPolling( + enabled = true, + frequency = DcaFrequency.CUSTOM, + cron = newCron.ifBlank { null }, + scheduleConfig = null ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = stringResource(R.string.settings_sell_polling_frequency), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold - ) - FrequencyDropdown( - selectedFrequency = uiState.sellPollingFrequency, - onFrequencySelected = { newFreq -> - viewModel.setPeriodicSellPolling( - enabled = true, - frequency = newFreq, - cron = null, - scheduleConfig = null - ) - } - ) - if (uiState.sellPollingFrequency == DcaFrequency.CUSTOM) { - var cronInput by rememberSaveable { - mutableStateOf(uiState.sellPollingCronExpression ?: "") - } - OutlinedTextField( - value = cronInput, - onValueChange = { newValue -> - cronInput = newValue - viewModel.setPeriodicSellPolling( - enabled = true, - frequency = DcaFrequency.CUSTOM, - cron = newValue.ifBlank { null }, - scheduleConfig = null - ) - }, - label = { Text(stringResource(R.string.settings_sell_polling_cron_label)) }, - placeholder = { Text(stringResource(R.string.settings_sell_polling_cron_hint)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - } - Text( - text = stringResource(R.string.settings_sell_polling_battery_note), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } } - } + ) } } @@ -1241,34 +1200,98 @@ internal fun TradingToggleCard( } @Composable -internal fun SellPollingToggleCard( +internal fun SellPollingCard( isEnabled: Boolean, - onToggle: (Boolean) -> Unit + frequency: DcaFrequency, + cronExpression: String?, + onToggle: (Boolean) -> Unit, + onFrequencySelected: (DcaFrequency) -> Unit, + onCronExpressionChange: (String) -> Unit ) { val accent = successColor() val haptic = LocalHapticFeedback.current - SettingsCardBase( - title = stringResource(R.string.settings_sell_polling_title), - subtitle = stringResource(R.string.settings_sell_polling_subtitle), - icon = Icons.Default.Sync, - iconTint = if (isEnabled) accent else MaterialTheme.colorScheme.onSurfaceVariant, - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onToggle(!isEnabled) - }, - cardModifier = Modifier.semantics(mergeDescendants = true) { role = Role.Switch }, - trailing = { - Switch( - checked = isEnabled, - onCheckedChange = null, - modifier = Modifier.clearAndSetSemantics {}, - colors = SwitchDefaults.colors( - checkedThumbColor = accent, - checkedTrackColor = accent.copy(alpha = 0.5f) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Switch) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onToggle(!isEnabled) + } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Sync, + contentDescription = null, + tint = if (isEnabled) accent else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) ) - ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_sell_polling_title), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + Text( + text = stringResource(R.string.settings_sell_polling_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = isEnabled, + onCheckedChange = null, + modifier = Modifier.clearAndSetSemantics {}, + colors = SwitchDefaults.colors( + checkedThumbColor = accent, + checkedTrackColor = accent.copy(alpha = 0.5f) + ) + ) + } + AnimatedVisibility(visible = isEnabled) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + HorizontalDivider() + Text( + text = stringResource(R.string.settings_sell_polling_frequency), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + FrequencyDropdown( + selectedFrequency = frequency, + onFrequencySelected = onFrequencySelected + ) + if (frequency == DcaFrequency.CUSTOM) { + val cronExpr = cronExpression ?: "" + val cronDesc = CronUtils.describeCron(cronExpr) + ScheduleBuilder( + cronExpression = cronExpr, + cronDescription = cronDesc, + cronError = null, + onCronExpressionChange = onCronExpressionChange + ) + } + Text( + text = stringResource(R.string.settings_sell_polling_battery_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } - ) + } } @Composable diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index a500f27..5326ea8 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -91,6 +92,21 @@ fun PortfolioScreen( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } + // Sell wizard bottom sheet + var sellWizardPlanId by rememberSaveable { mutableStateOf(null) } + sellWizardPlanId?.let { planId -> + com.accbot.dca.presentation.screens.plans.sell.SellWizardBottomSheet( + planId = planId, + onDismiss = { sellWizardPlanId = null } + ) + } + + val currentPage = uiState.pages.getOrNull(uiState.selectedPageIndex) + val currentPlanId = (currentPage as? PairPage.Plan)?.planId + val onCreateSellOrder: (() -> Unit)? = if (currentPlanId != null && uiState.currentPlanAllowsSells) { + { sellWizardPlanId = currentPlanId } + } else null + // Landscape: two-pane layout – chart left, controls right if (isLandscape) { val chartData = uiState.chartData @@ -356,6 +372,7 @@ fun PortfolioScreen( onToggleCryptoGroupLineVisibility = { crypto, type -> viewModel.toggleCryptoGroupLineVisibility(crypto, type) }, onToggleAdvancedLegend = { viewModel.toggleAdvancedLegendExpanded() }, onToggleLimitLinesVisibility = { viewModel.toggleLimitLinesVisibility() }, + onCreateSellOrder = onCreateSellOrder, onRefresh = { viewModel.syncPricesAndLoadChart() }, onChartTouching = onChartTouching, modifier = Modifier.padding(paddingValues) @@ -383,6 +400,7 @@ internal fun PortfolioContent( onToggleCryptoGroupLineVisibility: (String, CryptoGroupLineType) -> Unit, onToggleAdvancedLegend: () -> Unit, onToggleLimitLinesVisibility: () -> Unit = {}, + onCreateSellOrder: (() -> Unit)? = null, onRefresh: () -> Unit, onChartTouching: (Boolean) -> Unit = {}, modifier: Modifier = Modifier @@ -688,13 +706,33 @@ internal fun PortfolioContent( } } - // Open sell-orders collapsible section (only on per-plan pages with sells enabled) - if (uiState.currentPlanAllowsSells && openSells.isNotEmpty()) { - item(key = "portfolio-open-sells-section") { - com.accbot.dca.presentation.screens.portfolio.components.OpenSellsCollapsibleSection( - openSells = openSells, - onCancelClick = onCancelSell - ) + // Open sell-orders section + new order button (only on per-plan pages with sells enabled) + if (uiState.currentPlanAllowsSells) { + if (openSells.isNotEmpty()) { + item(key = "portfolio-open-sells-section") { + com.accbot.dca.presentation.screens.portfolio.components.OpenSellsCollapsibleSection( + openSells = openSells, + onCancelClick = onCancelSell + ) + } + } + if (onCreateSellOrder != null) { + item(key = "portfolio-new-sell-order") { + OutlinedButton( + onClick = onCreateSellOrder, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.portfolio_new_sell_order)) + } + } } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt index be5eae5..50b0383 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt @@ -74,7 +74,7 @@ data class PortfolioUiState( val currentPairFiat: String? = null, val totalTransactions: Int = 0, val visibleSeries: Set = setOf(0, 1), - val limitLinesVisible: Boolean = true, + val limitLinesVisible: Boolean = false, val scrubbedIndex: Int? = null, val planLines: List = emptyList(), val visiblePlanLines: Set> = emptySet(), From 048ea1eefc0bf7505f5a347ee2af2d90d2861a79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 1 May 2026 13:13:37 +0200 Subject: [PATCH 36/75] feat(sell): localize SellWizardBottomSheet and unify Czech terminology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract all hardcoded Czech strings from SellWizardBottomSheet into strings.xml (EN) and values-cs/strings.xml (CZ). Replace "sell order" with "prodejní příkaz" consistently across Czech resources. Co-Authored-By: Claude Opus 4.6 --- .../plans/sell/SellWizardBottomSheet.kt | 61 ++++++++++--------- .../app/src/main/res/values-cs/strings.xml | 49 ++++++++++++--- .../app/src/main/res/values/strings.xml | 33 +++++++++- 3 files changed, 103 insertions(+), 40 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index 43e3ac1..525bcf4 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -40,11 +40,13 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.accbot.dca.R import com.accbot.dca.domain.usecase.SellValidation import com.accbot.dca.presentation.ui.theme.Error import com.accbot.dca.presentation.ui.theme.successColor @@ -103,11 +105,11 @@ private fun SellInputStep( // Header Row(verticalAlignment = Alignment.CenterVertically) { IconButton(onClick = onDismiss) { - Icon(Icons.Default.Close, contentDescription = "Zavrit") + Icon(Icons.Default.Close, contentDescription = null) } Column(modifier = Modifier.weight(1f)) { Text( - text = "Limit sell ${state.crypto}/${state.fiat}", + text = stringResource(R.string.sell_wizard_title, state.crypto, state.fiat), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) @@ -123,21 +125,21 @@ private fun SellInputStep( // Info block InfoRow( - "Aktualni cena:", + stringResource(R.string.sell_wizard_spot_price), state.spotPrice?.let { "${NumberFormatters.fiat(it)} ${state.fiat}" } ?: "-" ) InfoRow( - "Prum. nakup:", + stringResource(R.string.sell_wizard_avg_buy), state.avgBuyPrice?.let { "${NumberFormatters.fiat(it)} ${state.fiat}" } ?: "-" ) InfoRow( - "K dispozici:", + stringResource(R.string.sell_wizard_available), "${NumberFormatters.crypto(state.availableToSell)} ${state.crypto}" ) Spacer(Modifier.height(16.dp)) Text( - "Mnozstvi", + stringResource(R.string.sell_wizard_amount), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold ) @@ -160,7 +162,8 @@ private fun SellInputStep( modifier = Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - listOf(25 to "25%", 50 to "50%", 75 to "75%", 100 to "Vse").forEach { (pct, label) -> + val allLabel = stringResource(R.string.sell_wizard_amount_all) + listOf(25 to "25%", 50 to "50%", 75 to "75%", 100 to allLabel).forEach { (pct, label) -> AssistChip( onClick = { vm.setAmountPct(pct) }, label = { Text(label) } @@ -170,7 +173,7 @@ private fun SellInputStep( Spacer(Modifier.height(16.dp)) Text( - "Limitni cena", + stringResource(R.string.sell_wizard_limit_price), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold ) @@ -195,7 +198,7 @@ private fun SellInputStep( ) { AssistChip( onClick = vm::setPriceSpot, - label = { Text("Trzni") }, + label = { Text(stringResource(R.string.sell_wizard_chip_spot)) }, enabled = state.spotPrice != null ) AssistChip( @@ -217,7 +220,7 @@ private fun SellInputStep( Spacer(Modifier.height(16.dp)) Text( - "Souhrn", + stringResource(R.string.sell_wizard_summary), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold ) @@ -225,7 +228,7 @@ private fun SellInputStep( val amountBD = state.amountInput.toBigDecimalOrNull() ?: BigDecimal.ZERO val priceBD = state.priceInput.toBigDecimalOrNull() ?: BigDecimal.ZERO val proceeds = (amountBD * priceBD).setScale(2, RoundingMode.HALF_UP) - InfoRow("Ziskate:", "${NumberFormatters.fiat(proceeds)} ${state.fiat}") + InfoRow(stringResource(R.string.sell_wizard_proceeds), "${NumberFormatters.fiat(proceeds)} ${state.fiat}") state.avgBuyPrice?.let { avg -> val profit = ((priceBD - avg) * amountBD).setScale(2, RoundingMode.HALF_UP) val profitPct = if (avg > BigDecimal.ZERO) { @@ -235,7 +238,7 @@ private fun SellInputStep( } else BigDecimal.ZERO val sign = if (profit >= BigDecimal.ZERO) "+" else "" InfoRow( - label = "Zisk vs prum:", + label = stringResource(R.string.sell_wizard_profit_vs_avg), value = "$sign${NumberFormatters.fiat(profit)} ${state.fiat} ($sign${profitPct.toPlainString()}%)", color = when { profit > BigDecimal.ZERO -> successColor() @@ -256,10 +259,10 @@ private fun SellInputStep( modifier = Modifier.padding(vertical = 4.dp) ) is SellValidation.InstantFillInfo -> InfoBanner( - "Prodej probehne okamzite. Limitni cena je pod aktualni trzni (${NumberFormatters.fiat(v.spot)} ${state.fiat}). Prikaz se zfilluje ihned za nejvyssi nabidku na burze (obvykle blizko trzni ceny minus spread). Neni to chyba." + stringResource(R.string.sell_wizard_instant_fill_warning, NumberFormatters.fiat(v.spot), state.fiat) ) is SellValidation.FarFromMarketWarning -> WarningBanner( - "Cena je vysoko nad trhem - prodej se nemusi zfillovat dlouho." + stringResource(R.string.sell_wizard_far_from_market_warning) ) is SellValidation.Ok -> { /* no-op */ } } @@ -271,7 +274,7 @@ private fun SellInputStep( enabled = state.canProceed, modifier = Modifier.fillMaxWidth() ) { - Text("Pokracovat") + Text(stringResource(R.string.sell_wizard_proceed)) } Spacer(Modifier.height(32.dp)) @@ -295,10 +298,10 @@ private fun SellConfirmStep( ) { Row(verticalAlignment = Alignment.CenterVertically) { IconButton(onClick = vm::back) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zpet") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) } Text( - "Potvrdit prodej", + stringResource(R.string.sell_wizard_confirm_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) @@ -306,16 +309,16 @@ private fun SellConfirmStep( Spacer(Modifier.height(12.dp)) - SummaryRow("Burza:", state.exchangeName) - SummaryRow("Plan:", state.planName) - SummaryRow("Smer:", "PRODEJ") - SummaryRow("Mnozstvi:", "${NumberFormatters.crypto(amountBD)} ${state.crypto}") - SummaryRow("Limitni cena:", "${NumberFormatters.fiat(priceBD)} ${state.fiat}") - SummaryRow("Ziskate:", "${NumberFormatters.fiat(proceeds)} ${state.fiat}") + SummaryRow(stringResource(R.string.sell_wizard_confirm_exchange), state.exchangeName) + SummaryRow(stringResource(R.string.sell_wizard_confirm_plan), state.planName) + SummaryRow(stringResource(R.string.sell_wizard_confirm_side), stringResource(R.string.sell_wizard_confirm_side_sell)) + SummaryRow(stringResource(R.string.sell_wizard_confirm_amount), "${NumberFormatters.crypto(amountBD)} ${state.crypto}") + SummaryRow(stringResource(R.string.sell_wizard_confirm_limit_price), "${NumberFormatters.fiat(priceBD)} ${state.fiat}") + SummaryRow(stringResource(R.string.sell_wizard_confirm_proceeds), "${NumberFormatters.fiat(proceeds)} ${state.fiat}") Spacer(Modifier.height(16.dp)) WarningBanner( - "Tato akce odesle prikaz na ${state.exchangeName} a nelze ji vratit. Prikaz lze pote zrusit, dokud neni castecne nebo cele zfillovan." + stringResource(R.string.sell_wizard_confirm_warning, state.exchangeName) ) state.submitError?.let { err -> @@ -338,7 +341,7 @@ private fun SellConfirmStep( enabled = !state.submitting, modifier = Modifier.weight(1f) ) { - Text("Zpet") + Text(stringResource(R.string.sell_wizard_back)) } Button( onClick = { vm.submit() }, @@ -352,7 +355,7 @@ private fun SellConfirmStep( color = LocalContentColor.current ) } else { - Text("Odeslat") + Text(stringResource(R.string.sell_wizard_submit)) } } } @@ -363,11 +366,9 @@ private fun SellConfirmStep( if (state.showTimeoutDialog) { AlertDialog( onDismissRequest = vm::dismissTimeoutDialog, - title = { Text("Nelze overit stav prikazu") }, + title = { Text(stringResource(R.string.sell_wizard_timeout_title)) }, text = { - Text( - "Spojeni s burzou selhalo nebo timeoutovalo. Prikaz mohl byt odeslan, ale nelze to potvrdit. Zkontroluj otevrene ordery na burze pres web a v pripade potreby zrus duplicitu." - ) + Text(stringResource(R.string.sell_wizard_timeout_text)) }, confirmButton = { Button(onClick = vm::dismissTimeoutDialog) { Text("OK") } diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index c8e3086..71bd64f 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -264,7 +264,8 @@ Prodej - %1$s: %2$d otevřených sell orderů + %1$s: %2$d otevřených prodejních příkazů + %1$d otevřených prodejních příkazů Vytvořit DCA plán @@ -324,7 +325,7 @@ %1$d transakcí smazáno + Vytvořit prodejní příkaz Nelze smazat plán - Tento plán má %1$d otevřených sell orderů. Zruš je nejdřív, než plán smažeš. + Tento plán má %1$d otevřených prodejních příkazů. Zruš je nejdřív, než plán smažeš. Upravit plán @@ -340,7 +341,8 @@ Načítání pozic… Realizováno Čistý P&L - Otevřené sell ordery (%1$d) + Otevřené prodejní příkazy (%1$d) + Nový prodejní příkaz Všechny páry @@ -447,12 +449,12 @@ Čekající Částečně vyplněno v - Sell order + Prodejní příkaz Limitní cena Vyplněno Průměrná cena plnění - Zrušit order - Zrušit order? + Zrušit příkaz + Zrušit příkaz? Opravdu zrušit limitní prodej? @@ -954,8 +956,8 @@ POKROČILÉ Povolit prodeje Umožní u vybraných plánů zadávat limitní prodejní příkazy a sledovat P&L. - Kontrolovat sell ordery na pozadí - Periodická kontrola stavu orderů. Zvyšuje spotřebu baterie. + Kontrolovat prodejní příkazy na pozadí + Periodická kontrola stavu příkazů. Zvyšuje spotřebu baterie. Frekvence Vlastní plán (CRON) např. 0 *\/2 * * * @@ -971,6 +973,35 @@ Vypnout prodeje? - Máš %1$d otevřených sell orderů. Vypnutím prodejů se skryje sell sekce, ale ordery na burze zůstávají. Musíš je zrušit ručně přes burzu, nebo zapnutím prodejů a kliknutím Cancel. + Máš %1$d otevřených prodejních příkazů. Vypnutím prodejů se skryje sekce prodejů, ale příkazy na burze zůstávají. Musíš je zrušit ručně přes burzu, nebo zapnutím prodejů a zrušením příkazů. Vypnout + + + Limitní prodej %1$s/%2$s + Aktuální cena: + Průměrný nákup: + K dispozici: + Množství + Vše + Limitní cena + Tržní + Souhrn + Získáte: + Zisk vs průměr: + Prodej proběhne okamžitě. Limitní cena je pod aktuální tržní (%1$s %2$s). Příkaz se vyplní ihned za nejvyšší nabídku na burze (obvykle blízko tržní ceny minus spread). Není to chyba. + Cena je vysoko nad trhem - prodej se nemusí vyplnit dlouho. + Pokračovat + Potvrdit prodej + Burza: + Plán: + Směr: + PRODEJ + Množství: + Limitní cena: + Získáte: + Tato akce odešle příkaz na %1$s a nelze ji vrátit. Příkaz lze poté zrušit, dokud není částečně nebo celý vyplněn. + Zpět + Odeslat + Nelze ověřit stav příkazu + Spojení s burzou selhalo nebo vypršelo. Příkaz mohl být odeslán, ale nelze to potvrdit. Zkontroluj otevřené příkazy na burze přes web a v případě potřeby zruš duplicitu. diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 7f3148e..4604cc6 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -264,6 +264,7 @@ %1$s: %2$d open sell order(s) + %1$d open sell order(s) Create DCA Plan @@ -321,7 +322,7 @@ Type %1$d to confirm Delete All Transactions %1$d transactions deleted - + Vytvorit prodejni prikaz + + Create sell order Cannot delete plan This plan has %1$d open sell order(s). Cancel them first before deleting the plan. @@ -340,6 +341,7 @@ Realized Net P&L Open sell orders (%1$d) + New sell order All Pairs @@ -967,4 +969,33 @@ Turn off sells? You have %1$d open sell order(s). Turning sells off will hide the sell section, but the orders remain on the exchange. You must cancel them manually on the exchange, or by turning sells back on and clicking Cancel. Turn off + + + Limit sell %1$s/%2$s + Current price: + Avg buy price: + Available: + Amount + All + Limit price + Market + Summary + Proceeds: + Profit vs avg: + The sell will execute immediately. The limit price is below the current market price (%1$s %2$s). The order will fill at the highest bid on the exchange (usually close to market price minus spread). This is not an error. + The price is far above market - the order may take a long time to fill. + Continue + Confirm sell + Exchange: + Plan: + Side: + SELL + Amount: + Limit price: + Proceeds: + This action will submit an order to %1$s and cannot be undone. The order can be cancelled later, as long as it has not been partially or fully filled. + Back + Submit + Cannot verify order status + Connection to the exchange failed or timed out. The order may have been submitted, but cannot be confirmed. Check open orders on the exchange via web and cancel any duplicates if needed. From 2cbc04be6a7bbb11fa816f7556310af5ba912a96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 12:00:55 +0200 Subject: [PATCH 37/75] docs(sell): cost basis calculator + ladder mode design spec Spec for sell wizard enhancement: timestamp-aware cheapest-first cost basis algorithm, three-field calculator (amount/price/net), loss warning on net profit < 0, optional ladder mode for scale-out strategies. Stateless design, no schema migration. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...05-09-sell-cost-basis-and-ladder-design.md | 338 ++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-09-sell-cost-basis-and-ladder-design.md diff --git a/docs/superpowers/specs/2026-05-09-sell-cost-basis-and-ladder-design.md b/docs/superpowers/specs/2026-05-09-sell-cost-basis-and-ladder-design.md new file mode 100644 index 0000000..d1a91e3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-09-sell-cost-basis-and-ladder-design.md @@ -0,0 +1,338 @@ +# Sell wizard - cost basis kalkulacka a ladder mode + +**Datum:** 2026-05-09 +**Branch:** feature/dca-sell-extension +**Status:** Navrh ke schvaleni + +## Motivace + +Rucni nastaveni sellu otevira prostor emocionalnimu rozhodovani (panic sell, FOMO, anchoring). Soucasny sell wizard pracuje jen s "kolik a za kolik", bez kontextu kolik koin si uzivatel poridil za jakou cenu. Uzivatel tak nevidi, jestli dnesni cena je nad/pod jeho cost basis a jak by sell ovlivnil zbyvajici prumernou nakupni cenu. + +Tato funkce pridava: + +1. **Vypocteny remaining cost basis** v sell wizardu (timestamp-aware cheapest-first), s moznosti manualniho prepsani. +2. **Tripolovou kalkulacku** (mnozstvi / limit cena / cisty vynos) s automatickym doplnovanim treti hodnoty z dvou zadanych. +3. **Profit preview** v summary vcetne "remaining avg po prodeji". +4. **Loss warning** banner pro prodeje pod cost basis. +5. **Volitelny ladder mod** (checkbox "Vytvorit vice sell orderu") pro pre-commitovane scale-out strategie. + +Cil: uzivatel vidi v okamziku zadani vsechna relevantni cisla a rozhoduje se na zaklade faktu, ne emoci. + +## Pozadi v kodu + +- Sell wizard: `accbot-android/.../presentation/screens/plans/sell/SellWizardBottomSheet.kt` + `SellWizardViewModel.kt` +- Validace: `domain/usecase/ValidateSellOrderUseCase.kt` (vraci sealed `SellValidation`, vicenasobne vysledky v listu) +- Provedeni: `domain/usecase/PlaceLimitSellUseCase.kt` (dnes 1 order, vlozi PENDING SELL transakci) +- PnL: `domain/usecase/CalculatePlanPnLUseCase.kt` (lifetime accounting - **zustava beze zmeny**) +- Polling: `worker/SellPollingWorker.kt` (synchronizace z burzy - **zustava beze zmeny**) +- Schema: `TransactionEntity` ma `side`, `cryptoAmount`, `requestedCryptoAmount`, `executedAt`, `status` - vse uz existuje. **Zadna migrace.** + +## Navrh + +### 1. Cost basis algoritmus (timestamp-aware cheapest-first) + +Novy use case `CalculatePlanCostBasisUseCase`, cista funkce. + +**Vstup:** `planId: Long` + +**Postup:** + +1. Nacist vsechny transakce planu (BUY + SELL). +2. Vyfiltrovat relevantni stavy: + - BUYs: `COMPLETED` nebo `PARTIAL` + - SELLs: `COMPLETED`, `PARTIAL`, `PENDING` (pending blokuji inventar) +3. Inicializovat `consumed[buyId] = 0` pro kazdy buy. +4. Pro kazdy sell v poradi podle `executedAt ASC`: + - Filtr buyu, ktere maji `buy.executedAt < sell.executedAt` AND `buy.cryptoAmount - consumed[buy.id] > 0` (zbyva nezkonzumovana cast). + - Seradit ASC podle `buy.price`. Tie-break: starsi `executedAt` napred. + - Cilova konzumace: + - COMPLETED/PARTIAL sell: `sell.cryptoAmount` + - PENDING/PARTIAL sell: `sell.requestedCryptoAmount - sell.cryptoAmount` (= unfilled reservation) + - Konzumovat sekvencne: pro kazdy buy v poradi `take = min(remaining_in_buy, remaining_to_consume)`, zvysit `consumed[buy.id] += take`, snizit `remaining_to_consume -= take`. Pokud po vycerpani vsech eligible buyu zbyva `remaining_to_consume > 0` -> spadne pod edge case "negative inventory" (vraci se ve vystupu). +5. Po projiti vsech sells: + - `remainingPerBuy[buy] = buy.cryptoAmount - consumed[buy.id]` (ulozit jen kde > 0) + - `available = sum(remainingPerBuy)` + - `weightedAvgPrice = available > 0 ? sum(remaining × buy.price) / available : null` + +**Vystup:** + +```kotlin +data class RemainingInventory( + val available: BigDecimal, // sum zbyvajicich crypto + val weightedAvgPrice: BigDecimal?, // null pokud available == 0 + val perBuyDetail: List, // pro debug / future features + val deficit: BigDecimal // > 0 pokud sells presahly buys +) +``` + +**Vlastnosti:** + +- Plne stateless. Zadna DB schema zmena, zadna persistence, zadny backup tweak. +- Stabilni vuci novym buyum: novy buy s `executedAt > existing_sells` ma `consumed = 0`, plne se zapocita do remaining. +- Reservace z PENDING/PARTIAL nezdvoji prodej cheap inventory. +- Performance: `O(sells × buys × log(buys))` na sort. Pro 2000 buys + 50 sells ~ 1M ops, jednotky ms. **Cache je YAGNI pro v1.** + +### 2. Tripolova kalkulacka + +**Pole** v sell wizardu Krok 1: + +| Pole | Symbol | Vyznam | +|---|---|---| +| Avg buy price | `avg` | cost basis (prefill z algoritmu, editovatelny) | +| Mnozstvi crypto | `A` | kolik prodat | +| Limit cena | `P` | fiat / crypto | +| Cisty vynos | `N` | fiat na ucet po fee | + +**Vztah:** `N = A × P × (1 - feeRate)` + +**Logika kalkulacky:** + +ViewModel si pamatuje `lastTwoEdited: Pair` (FIFO poradi mezi A/P/N). Kdyz uzivatel napise hodnotu do pole `X`: + +1. Pridat `X` na konec `lastTwoEdited`, vyhodit nejstarsi. +2. Pokud jsou vsechna 3 pole vyplnena: dopocitat to, ktere NENI v `lastTwoEdited`. +3. Pokud jen 2 jsou vyplnena: dopocitat 3. +4. Pokud jen 1: nedelat nic. + +**Rovnice:** + +- `(A, P) -> N = A × P × (1 - feeRate)` +- `(A, N) -> P = N / (A × (1 - feeRate))` +- `(P, N) -> A = N / (P × (1 - feeRate))` + +**Avg pole je separatni vstup**, neni soucasti 3-pole kalkulacky (nemeni A/P/N primo, jen ovlivnuje profit ve summary). Tlacitko "Spocitat z planu" resetuje na auto-prefill z `CalculatePlanCostBasisUseCase`. + +### 3. Fee plumbing + +Rozsirit `ExchangeApi` interface o: + +```kotlin +val estimatedTakerFeeRate: BigDecimal +``` + +Hodnoty: + +| Burza | feeRate | Zdroj | +|---|---|---| +| Coinmate | 0.0035 | dnes hardcoded v `CoinmateApi.kt:32` | +| Binance | 0.001 | default taker, ignoruje BNB/VIP discounty | +| KuCoin | 0.001 | default taker | +| Coinbase | 0.0040 | advanced trade base tier | +| Kraken | 0.0026 | base tier | +| Bitfinex / Huobi | 0.002 | placeholder, validace stejne dnes vraci `false` | + +V summary radek `Odhadovany fee: X CZK (0.35%)` pro transparentnost. Pokud uzivatel ma nizsi fee tier nebo BNB discount, dostane mirne vic - to je akceptovatelne pro decision support. + +### 4. Cenove a vynosove presety + +**Pod polem `Limit cena`** dropdown menu s rezimem: + +- **% z avg buy** (default): `P = avg × (1 + preset)`. Hodnoty: +5%, +10%, +20%, +50%. +- **% ze spotu**: `P = spot × (1 + preset)`. Stejne hodnoty. + +Toggle se ulozi pro session (transient, neperzistovat). + +**Pod polem `Mnozstvi`:** zachovat existujici 25% / 50% / 75% / 100% z `available`. + +**Pod polem `Cisty vynos`:** presety relativni k cost basis. Cil = "kolik chci na transakci vydelat". + +`N = A × avg × (1 + profitTarget)` (cislo, ktere by mi prislo na ucet, kdyby fee byl 0; system pak dopocita `P` zpetne pres `P = N / (A × (1 - feeRate))`, fee je implicitne zahrnut). + +Hodnoty: +10%, +20%, +50%, +100% + +### 5. Loss warning + +`ValidateSellOrderUseCase` doplnit o: + +```kotlin +data class LossWarning(val lossFiat: BigDecimal, val lossPct: Double) : SellValidation() +``` + +**Trigger: `netProfit < 0`**, kde `netProfit = N - A × avg = A × P × (1 - feeRate) - A × avg`. + +Pozor: trigger neni jen `P < avg`. Pri P tesne nad avg muze fee uz dostat transakci do realne ztraty. Banner reflektuje skutecnou ekonomickou realitu (anti-emocionalni cil = videt pravdu). + +Banner formulace: +- `P < avg`: "Prodavas pod nakupni cenou: -X CZK" +- `P >= avg`, `netProfit < 0`: "Po fee prodavas se ztratou: -X CZK" + +Cervene formatovani zisku, wizard normalne projde. **Zadny hard block, zadna dvojita konfirmace.** Pokud uzivatel po nasazeni zjisti, ze potrebuje silnejsi friction, lze pridat pozdeji. + +### 6. Summary rozsireni + +Sell wizard summary (Krok 1 i Krok 2) zobrazi: + +``` +--- Souhrn --- +Avg nakupni cena: 1 870 000 CZK [auto / ✏️ rucne] +Profit per coin: +230 000 CZK +Hruby zisk: +5 750 CZK +Odhad fee: -184 CZK (0.35%) +Cisty zisk: +5 566 CZK (+12.3%) +Po prodeji: 0.18 BTC, avg 1 920 000 CZK +Postup k cili: 18 666 / 25 000 CZK (75%) +``` + +**Vypocty:** + +- "Hruby zisk" = `A × (P - avg)` +- "Odhad fee" = `A × P × feeRate` +- "Cisty zisk" = `N - A × avg` (kde `N = A × P × (1 - feeRate)`) +- "Cisty zisk %" = `cistyZisk / (A × avg)` +- "Po prodeji - avg" = stejny algoritmus z #1, ale s timto hypotetickym sellem zahrnutym mezi historicke (smaze cheapest-first ze zbytku po existujicich pending+real sells) +- "Postup k cili" = `(realizedPnL + cistyZisk_thisTx) / plan.targetProfitAmount`. Jen pokud `targetProfitAmount != null`. + +**Loss case** (`P < avg`): "Cisty zisk" se zobrazi cervene jako "**-X CZK (-Y%)**", plus banner. + +### 7. Ladder mode (volitelny) + +**Aktivace:** checkbox "Vytvorit vice sell orderu" v Kroku 1. + +**UI po zaskrtnuti:** + +- Pole `Limit cena` se nahradi dvojici `Od` / `Do`. +- Toggle uvnitr "Cena | Profit %" prepina mezi absolutnimi cenami a % nad cost basis. +- Pole `Cisty vynos` se skryje (nedava smysl pro ladder, derivuje se v preview). +- Nove pole `Pocet orderu` (cele cislo, default 5, min 2, max 10). +- Toggle "Equal crypto | Equal fiat": + - **Equal crypto**: kazdy order ma `A_i = total / N` BTC. + - **Equal fiat**: kazdy order ma `A_i = (totalFiatGross / N) / P_i` BTC, takze kazdy vygeneruje stejny gross fiat. +- Distribuce cen: linear, `P_i = from + (to - from) × i / (N - 1)` pro `i = 0..N-1`. + +**Preview tabulka** vzdy viditelna pod inputs, re-renders na kazdou zmenu: + +``` +# Mnozstvi Cena Profit % Cisty vynos +1 0.05 BTC 2 000 000 +12.3% 99 650 +2 0.05 BTC 2 100 000 +17.6% 104 632 +3 0.05 BTC 2 200 000 +22.9% 109 615 +4 0.05 BTC 2 300 000 +28.1% 114 597 +5 0.05 BTC 2 400 000 +33.4% 119 580 + -------- + Celkem: 548 074 CZK +``` + +Souhrn pod tabulkou: total profit (sum of profits), avg po prodeji vsech orderu (kdyby vsechny fillnuly), postup k cili. + +### 8. Provedeni v ladder modu + +Novy use case `PlaceLadderSellUseCase`. Vstup: `planId, List`. + +**Failure handling: stop & report.** + +1. Iterovat ordery sekvencne. +2. Pro kazdy: zavolat `api.limitSell(...)`, vlozit PENDING SELL transakci. +3. Pri prvnim selhani zastavit, vratit `Result(placedTxIds: List, failedAtIndex: Int, reason: String)`. +4. UI zobrazi `"Vytvoreno X z N orderu. Zbyvajici nepokracovaly: . Muzes zkusit znovu pro zbyvajici."` +5. Zadny auto-rollback. Pokud uzivatel chce zrusit uz vytvorene, pouzije existujici cancel ikonu na plan-detail. + +### 9. Validace v ladder modu + +`ValidateSellOrderUseCase` rozsirit o ladder validation: + +- Total amount ≤ available (cost-basis-aware, viz #1) +- Per-order amount ≥ minOrderSize: `(total / N) ≥ minOrderSize` (equal-crypto), nebo `min(A_i) ≥ minOrderSize` (equal-fiat) +- `from > 0`, `to > from`, `N >= 2` +- Pokud profit % mod: `from`, `to` mohou byt i zaporne, vrati LossWarning +- Pokud absolutni ceny: `from > 3 × spot` -> `FarFromMarketWarning` +- LossWarning agreguje: `sum(amount_i × max(0, avg - P_i))` napric ordery + +## UI rozlozeni + +``` ++- Sell wizard - Krok 1 ----------+ +| Avg nakupni cena ⓘ | +| [ 1 870 000 CZK ] (auto) | +| [ Spocitat z planu ] | +| | +| ☐ Vytvorit vice sell orderu | +| | +| Mnozstvi | +| [ 0.025 BTC ] | +| [25%][50%][75%][100%] | +| | +| Limit cena [▼ % z avg] | +| [ 2 100 000 CZK ] | +| [+5%][+10%][+20%][+50%] | +| | +| Cisty vynos | +| [ 52 316 CZK ] | +| [+10%][+20%][+50%][+100%] | +| | +| ⚠️ Prodavas se ztratou (red) | +| | +| --- Souhrn --- | +| Avg buy: 1 870 000 ✏️ | +| Profit per coin: +230 000 | +| Hruby zisk: +5 750 | +| Odhad fee: -184 (0.35%) | +| Cisty zisk: +5 566 (+12.3%) | +| Po prodeji: 0.18 BTC @ 1 920 000| +| Cil: 18 666 / 25 000 (75%) | +| | +| [ Pokracovat ] | ++---------------------------------+ +``` + +**Po zaskrtnuti ladder checkboxu:** + +- Limit cena → dvojice Od/Do + toggle "Cena | %" +- Cisty vynos pole skryto +- Pribude "Pocet orderu" + toggle "Equal crypto | Equal fiat" +- Summary se nahradi preview tabulkou + agregatem + +## Implementacni surface + +**Nove soubory:** + +- `domain/model/RemainingInventory.kt` - data class +- `domain/usecase/CalculatePlanCostBasisUseCase.kt` - algoritmus +- `domain/usecase/PlaceLadderSellUseCase.kt` - multi-order place + +**Modifikace:** + +- `exchange/ExchangeApi.kt` - pridat `estimatedTakerFeeRate` +- `exchange/CoinmateApi.kt`, `BinanceApi.kt`, `CoinbaseApi.kt`, `OtherExchanges.kt` - implementovat field +- `domain/usecase/ValidateSellOrderUseCase.kt` - `LossWarning`, ladder validation +- `presentation/screens/plans/sell/SellWizardViewModel.kt` - state machine pro 3-pole + ladder +- `presentation/screens/plans/sell/SellWizardBottomSheet.kt` - UI pole, presety, summary, ladder +- `res/values-cs/strings.xml`, `res/values/strings.xml` - nove stringy + +**Nemeni se:** + +- DB schema, migrace +- Backup/restore (`BackupDataCollector`, `BackupDataRestorer`) +- `CalculatePlanPnLUseCase` (zustava lifetime accounting) +- `SellPollingWorker`, `ResolvePendingTransactionsUseCase` +- `PlaceLimitSellUseCase` (single mod zustava nedotcen, ladder = nova cesta) + +**Odhad rozsahu:** 8-10 souboru. Zadna schema migrace. Testovat lze postupne (single mod nejdriv, pak ladder). + +## Edge cases + +- **Zadne buys / vse prodano**: `available = 0`, `avg = null`, prefill prazdny, vyzaduje manualni vstup. Wizard projde, validace se opira jen o manualni avg. +- **Negative inventory** (`deficit > 0`, sells > buys): banner "Inventar nesedi, zadej avg manualne". Wizard projde s manualnim avg. +- **PARTIAL buy**: pouzit `cryptoAmount` (skutecne koupene), ne `requestedCryptoAmount`. (DCA buys jsou typicky atomic, ale obecne OK.) +- **Multi-connection v planu**: nestane se. Plan ma jednu `connectionId`, sells jdou pres ni. +- **Plan target = null**: skryt radek "Postup k cili". +- **PENDING ladder rozsahem prekracujici available**: validate pred place, hard error. +- **Ladder s 1 orderem**: nedovolit. `N >= 2` (jinak pouzij single mod). +- **Manualni override avg na nesmyslnou hodnotu** (zaporna, 0): hard error v ValidateSellOrderUseCase. + +## Out of scope + +- **Cache cost basis vypoctu**: YAGNI v1. Vypocet je rychly pro realisticka data. Pokud by se ukazal jako problem, in-memory cache invalidovana z `TransactionDao` flow. +- **Snapshot avg na SELL transakci**: nepotrebujeme, timestamp-aware cheapest-first resi stabilitu. +- **Hard block na loss**: jen warning + visual cue, uzivatel rozhoduje. +- **Geometric distribuce v ladderu**: linear postacuje. +- **Atomic batch place** / rollback pri selhani mid-batch: stop & report staci. +- **Perzistovane preset preference** (% z avg vs % ze spotu): transient session-level. +- **Zmena PnL vypoctu na cheapest-first**: zustava lifetime accounting v `CalculatePlanPnLUseCase`. Mozna pridat druhy radek "remaining cost basis" do PnL card jako future enhancement. +- **Per-buy detail v summary** ("z toho 0.05 BTC z buy z 1.1.2026, 0.10 BTC z buy z 5.3.2026..."): k debugu/future, neni MVP. + +## Otevrene otazky pro planovaci fazi + +- **Poradi tasku v planu**: cost basis use case (s testy) → fee plumbing → wizard ViewModel rewrite → UI single mod → ladder mode. +- **TDD**: cost basis algoritmus ma dost edge cases, vyplati se napsat unit testy. UI a presety testovat manualne. +- **Lokalizace**: nove stringy v cs + en soucasne, v jednom kroku. +- **Manual E2E test**: zacleneni do existujiciho Task 33 (Coinmate manual sandbox) a Task 34 (Binance) z `2026-04-23-dca-sell-extension.md`. From ed0c451a092bbe36e2208e9ff2b9cff3e2c8f2a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 12:13:36 +0200 Subject: [PATCH 38/75] docs(sell): implementation plan for cost basis + ladder mode 21 tasks across 7 phases. TDD-first for cost basis algorithm, SellCalculatorMath, ladder generator, loss check. Manual E2E integrated with existing Task 33/34 sandbox checklists. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-09-sell-cost-basis-and-ladder.md | 2372 +++++++++++++++++ 1 file changed, 2372 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-09-sell-cost-basis-and-ladder.md diff --git a/docs/superpowers/plans/2026-05-09-sell-cost-basis-and-ladder.md b/docs/superpowers/plans/2026-05-09-sell-cost-basis-and-ladder.md new file mode 100644 index 0000000..694ebbd --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-sell-cost-basis-and-ladder.md @@ -0,0 +1,2372 @@ +# Sell wizard - cost basis + ladder Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rozsirit sell wizard o cost basis kalkulacku (timestamp-aware cheapest-first), tripolovou kalkulacku (mnozstvi/cena/cisty vynos) s fee math, loss warning, profit summary, a volitelny ladder mod pro scale-out strategie. Anti-emocionalni decision support. + +**Architecture:** Cost basis algoritmus je cista funkce (TDD-friendly), volana z `SellWizardViewModel`. Tripolova kalkulacka je separatni pure helper. ViewModel drzi state machine pro single i ladder mod. UI v existujicim `SellWizardBottomSheet` (rozsireni, ne nova obrazovka). Zadna DB schema zmena, vse stateless. + +**Tech Stack:** Kotlin, Jetpack Compose, Hilt DI, Room DAO (read-only pro nas), kotlinx.coroutines, JUnit 4 + Kotlin coroutines test (nove pridavame). + +**Spec:** `docs/superpowers/specs/2026-05-09-sell-cost-basis-and-ladder-design.md` + +**Existing files of interest:** +- `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt` +- `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt` +- `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt` +- `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLimitSellUseCase.kt` +- `accbot-android/app/src/main/java/com/accbot/dca/exchange/ExchangeApi.kt` +- `accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt:218` - `getTransactionsByPlanSync` +- `accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt` - `TransactionEntity` + +**Branch:** zustavame na `feature/dca-sell-extension`. + +--- + +## Faze 1: Foundation - model a algoritmus + +### Task 1: Pridat RemainingInventory data class + +**Files:** +- Create: `accbot-android/app/src/main/java/com/accbot/dca/domain/model/RemainingInventory.kt` + +- [ ] **Krok 1: Vytvorit soubor** + +```kotlin +package com.accbot.dca.domain.model + +import java.math.BigDecimal + +/** + * Vystup CalculatePlanCostBasisUseCase - timestamp-aware cheapest-first + * remaining inventory po aplikaci historickych a pending sells. + */ +data class RemainingInventory( + /** Soucet zbyvajiciho crypta napric vsemi buys s nezkonzumovanou casti. */ + val available: BigDecimal, + + /** Volume-weighted prumerna nakupni cena z [perBuyDetail]. Null kdyz available == 0. */ + val weightedAvgPrice: BigDecimal?, + + /** Per-buy zbytky (jen buys s remaining > 0). Pro debug a future per-fill features. */ + val perBuyDetail: List, + + /** > 0 kdyz historicke sells presahly buys (data inconsistency, napr. po importu). */ + val deficit: BigDecimal +) + +data class RemainingBuy( + val transactionId: Long, + val price: BigDecimal, + val remaining: BigDecimal +) +``` + +- [ ] **Krok 2: Build check** + +Run: +```bash +export JAVA_HOME="/c/Program Files/Android/Android Studio/jbr" +cd accbot-android && ./gradlew :app:compileDebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Krok 3: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/domain/model/RemainingInventory.kt +git commit -m "feat(sell): add RemainingInventory model for cost basis algorithm" +``` + +--- + +### Task 2: Setup unit test infra + +Projekt zatim nema `src/test/` (jen androidTest + screenshotTest). Pridame JUnit 4 + Kotlin test deps a vytvorime test source set. + +**Files:** +- Modify: `accbot-android/app/build.gradle.kts` +- Create: `accbot-android/app/src/test/java/com/accbot/dca/.gitkeep` (placeholder pro git) + +- [ ] **Krok 1: Pridat test dependencies** + +V `accbot-android/app/build.gradle.kts` v sekci `dependencies { ... }` pridat (pokud uz tam nejsou): + +```kotlin +testImplementation("junit:junit:4.13.2") +testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") +testImplementation("org.jetbrains.kotlin:kotlin-test:1.9.22") +``` + +(Verze sladit s existujicimi - mrknout do `gradle/libs.versions.toml` pokud projekt pouziva version catalog. Pokud existuje `testImplementation(libs.junit)` apod., pouzit aliasy.) + +- [ ] **Krok 2: Vytvorit test source set folder** + +```bash +mkdir -p accbot-android/app/src/test/java/com/accbot/dca +touch accbot-android/app/src/test/java/com/accbot/dca/.gitkeep +``` + +- [ ] **Krok 3: Build check** + +Run: +```bash +cd accbot-android && ./gradlew :app:compileDebugUnitTestKotlin +``` + +Expected: BUILD SUCCESSFUL (zadne testy zatim, jen kompilace test source setu). + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/build.gradle.kts accbot-android/app/src/test/ +git commit -m "chore: setup JUnit 4 + coroutines test infra" +``` + +--- + +### Task 3: CalculatePlanCostBasisUseCase + unit testy (TDD) + +**Approach:** Algoritmus extrahovat do pure funkce v companion objectu, aby se daly testovat bez DB / Hilt. + +**Files:** +- Create: `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCase.kt` +- Create: `accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCaseTest.kt` + +- [ ] **Krok 1: Napsat selhavajici testy nejdrive (TDD)** + +Soubor `accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCaseTest.kt`: + +```kotlin +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.TransactionEntity +import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.TransactionSide +import com.accbot.dca.domain.model.TransactionStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.math.BigDecimal +import java.time.Instant + +class CalculatePlanCostBasisUseCaseTest { + + private val t0: Instant = Instant.parse("2026-01-01T00:00:00Z") + private fun ts(daysOffset: Long): Instant = t0.plusSeconds(daysOffset * 86_400) + + private fun buy( + id: Long, + crypto: BigDecimal, + price: BigDecimal, + executedAt: Instant, + status: TransactionStatus = TransactionStatus.COMPLETED + ): TransactionEntity = TransactionEntity( + id = id, + planId = 1, + connectionId = 1, + exchange = Exchange.COINMATE, + crypto = "BTC", + fiat = "CZK", + fiatAmount = crypto * price, + cryptoAmount = crypto, + price = price, + fee = BigDecimal.ZERO, + feeAsset = "CZK", + status = status, + exchangeOrderId = "buy-$id", + executedAt = executedAt, + side = TransactionSide.BUY + ) + + private fun sell( + id: Long, + crypto: BigDecimal, + price: BigDecimal, + executedAt: Instant, + status: TransactionStatus = TransactionStatus.COMPLETED, + requested: BigDecimal? = null + ): TransactionEntity = TransactionEntity( + id = id, + planId = 1, + connectionId = 1, + exchange = Exchange.COINMATE, + crypto = "BTC", + fiat = "CZK", + fiatAmount = crypto * price, + cryptoAmount = crypto, + price = price, + fee = BigDecimal.ZERO, + feeAsset = "CZK", + status = status, + exchangeOrderId = "sell-$id", + executedAt = executedAt, + side = TransactionSide.SELL, + requestedCryptoAmount = requested ?: crypto, + limitPrice = price + ) + + @Test + fun `prazdny plan vraci available=0 a avg=null`() { + val result = CalculatePlanCostBasisUseCase.computeCostBasis(emptyList()) + assertEquals(BigDecimal.ZERO, result.available) + assertNull(result.weightedAvgPrice) + assertTrue(result.perBuyDetail.isEmpty()) + assertEquals(BigDecimal.ZERO, result.deficit) + } + + @Test + fun `jeden buy bez sells - available a avg jsou z buyu`() { + val txs = listOf(buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0))) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal("1").compareTo(result.available)) + assertEquals(0, BigDecimal("1000000").compareTo(result.weightedAvgPrice!!)) + } + + @Test + fun `dva buys, sell konzumuje cheapest first`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), // cheaper + buy(2, BigDecimal("1"), BigDecimal("2000000"), ts(1)), + sell(3, BigDecimal("0.5"), BigDecimal("2500000"), ts(2)) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + // 0.5 BTC zkonzumovano z 1M buyu, zbyva 0.5 BTC @ 1M + 1 BTC @ 2M + assertEquals(0, BigDecimal("1.5").compareTo(result.available)) + // weighted avg = (0.5 × 1M + 1 × 2M) / 1.5 = 5/3 M = 1666666.67 + val expected = BigDecimal("1666666.66666667") + assertEquals(0, expected.compareTo(result.weightedAvgPrice!!)) + } + + @Test + fun `novy levny buy po sellu neovlivni avg pro driv prodane`() { + // Buy 1M, sell 0.5 (consumes 0.5 z 1M), pak buy 800k cheaper. + // Cheapest-first by retroactivne mohl konzumovat 800k buy, ale timestamp filter to nedovoli. + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + sell(2, BigDecimal("0.5"), BigDecimal("2000000"), ts(1)), + buy(3, BigDecimal("0.5"), BigDecimal("800000"), ts(2)) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + // Sell @ts(1) vidi jen buy 1 (ts(0)). Konzumuje 0.5 z 1M. + // Remaining: 0.5 @ 1M + 0.5 @ 800k = avg (500k + 400k) / 1.0 = 900k + assertEquals(0, BigDecimal("1.0").compareTo(result.available)) + assertEquals(0, BigDecimal("900000").compareTo(result.weightedAvgPrice!!)) + } + + @Test + fun `PENDING sell rezervuje cheapest`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + sell(2, BigDecimal("0"), BigDecimal("3000000"), ts(1), + status = TransactionStatus.PENDING, requested = BigDecimal("0.5")) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + // Pending rezervuje 0.5 z 1M buyu. Remaining 0.5 @ 1M. + assertEquals(0, BigDecimal("0.5").compareTo(result.available)) + assertEquals(0, BigDecimal("1000000").compareTo(result.weightedAvgPrice!!)) + } + + @Test + fun `PARTIAL sell pouziva requested ne filled`() { + // PARTIAL: requested 0.5, filled 0.2 -> efektivne konzumuje 0.5 (cele rezervuje) + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + sell(2, BigDecimal("0.2"), BigDecimal("3000000"), ts(1), + status = TransactionStatus.PARTIAL, requested = BigDecimal("0.5")) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal("0.5").compareTo(result.available)) + } + + @Test + fun `FAILED sell se ignoruje`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + sell(2, BigDecimal("0.5"), BigDecimal("3000000"), ts(1), + status = TransactionStatus.FAILED) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal("1").compareTo(result.available)) + } + + @Test + fun `negative inventory - sells presahly buys, deficit non-zero`() { + val txs = listOf( + buy(1, BigDecimal("0.5"), BigDecimal("1000000"), ts(0)), + sell(2, BigDecimal("1"), BigDecimal("2000000"), ts(1)) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal.ZERO.compareTo(result.available)) + assertNull(result.weightedAvgPrice) + assertEquals(0, BigDecimal("0.5").compareTo(result.deficit)) + } + + @Test + fun `tie na cene rozhodne starsi executedAt napred`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + buy(2, BigDecimal("1"), BigDecimal("1000000"), ts(1)), + sell(3, BigDecimal("0.5"), BigDecimal("2000000"), ts(2)) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + // Sell konzumuje 0.5 ze starsiho buyu. Remaining: 0.5 z buy 1 + 1 z buy 2. + assertEquals(0, BigDecimal("1.5").compareTo(result.available)) + val cheap1 = result.perBuyDetail.firstOrNull { it.transactionId == 1L } + assertEquals(0, BigDecimal("0.5").compareTo(cheap1?.remaining ?: BigDecimal.ZERO)) + } +} +``` + +**Pozn.:** Pokud `TransactionEntity` ma jine pole / jine pojmenovani, sladit s realnym definici v `Entities.kt`. Vsechna pouzita pole tam jsou (`id`, `planId`, `connectionId`, `exchange`, `crypto`, `fiat`, `fiatAmount`, `cryptoAmount`, `price`, `fee`, `feeAsset`, `status`, `exchangeOrderId`, `executedAt`, `side`, `requestedCryptoAmount`, `limitPrice`). + +- [ ] **Krok 2: Spustit testy a overit ze selhavaji s "computeCostBasis is not defined"** + +Run: +```bash +cd accbot-android && ./gradlew :app:testDebugUnitTest --tests "com.accbot.dca.domain.usecase.CalculatePlanCostBasisUseCaseTest" +``` + +Expected: kompilacni chyba "unresolved reference: CalculatePlanCostBasisUseCase". + +- [ ] **Krok 3: Implementovat CalculatePlanCostBasisUseCase** + +Soubor `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCase.kt`: + +```kotlin +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.TransactionEntity +import com.accbot.dca.domain.model.RemainingBuy +import com.accbot.dca.domain.model.RemainingInventory +import com.accbot.dca.domain.model.TransactionSide +import com.accbot.dca.domain.model.TransactionStatus +import java.math.BigDecimal +import java.math.RoundingMode +import javax.inject.Inject + +/** + * Spocita zbyvajici inventar (a vazenou prumernou nakupni cenu) pro plan + * pomoci timestamp-aware cheapest-first algoritmu. + * + * Pure logika v [computeCostBasis] companion funkci pro snadne unit testovani. + */ +class CalculatePlanCostBasisUseCase @Inject constructor( + private val database: DcaDatabase +) { + suspend operator fun invoke(planId: Long): RemainingInventory { + val transactions = database.transactionDao().getTransactionsByPlanSync(planId) + return computeCostBasis(transactions) + } + + companion object { + fun computeCostBasis(transactions: List): RemainingInventory { + val buys = transactions.filter { + it.side == TransactionSide.BUY && + (it.status == TransactionStatus.COMPLETED || it.status == TransactionStatus.PARTIAL) + } + + val sells = transactions.filter { + it.side == TransactionSide.SELL && + (it.status == TransactionStatus.COMPLETED || + it.status == TransactionStatus.PARTIAL || + it.status == TransactionStatus.PENDING) + }.sortedBy { it.executedAt } + + val consumed = HashMap(buys.size) + for (b in buys) consumed[b.id] = BigDecimal.ZERO + + var totalDeficit = BigDecimal.ZERO + + for (sell in sells) { + val toConsume = effectiveConsumption(sell) + if (toConsume <= BigDecimal.ZERO) continue + var remaining = toConsume + + val eligible = buys + .filter { it.executedAt.isBefore(sell.executedAt) } + .filter { + (it.cryptoAmount - (consumed[it.id] ?: BigDecimal.ZERO)) > BigDecimal.ZERO + } + .sortedWith(compareBy({ it.price }, { it.executedAt })) + + for (b in eligible) { + if (remaining <= BigDecimal.ZERO) break + val available = b.cryptoAmount - (consumed[b.id] ?: BigDecimal.ZERO) + val take = remaining.min(available) + consumed[b.id] = (consumed[b.id] ?: BigDecimal.ZERO) + take + remaining -= take + } + + if (remaining > BigDecimal.ZERO) totalDeficit = totalDeficit + remaining + } + + val perBuyDetail = buys.mapNotNull { b -> + val left = b.cryptoAmount - (consumed[b.id] ?: BigDecimal.ZERO) + if (left > BigDecimal.ZERO) RemainingBuy(b.id, b.price, left) else null + } + + val available = perBuyDetail.fold(BigDecimal.ZERO) { acc, rb -> acc + rb.remaining } + val weightedAvg = if (available > BigDecimal.ZERO) { + val sumCost = perBuyDetail.fold(BigDecimal.ZERO) { acc, rb -> + acc + rb.remaining * rb.price + } + sumCost.divide(available, 8, RoundingMode.HALF_UP) + } else null + + return RemainingInventory( + available = available, + weightedAvgPrice = weightedAvg, + perBuyDetail = perBuyDetail, + deficit = totalDeficit + ) + } + + /** + * Mnozstvi crypta, ktere ten sell rezervuje/zkonzumuje. Pro PENDING/PARTIAL = requested + * (cela rezervace, vc. unfilled cast). Pro COMPLETED = cryptoAmount (= requested). + */ + private fun effectiveConsumption(sell: TransactionEntity): BigDecimal { + val requested = sell.requestedCryptoAmount ?: BigDecimal.ZERO + return requested.max(sell.cryptoAmount) + } + } +} +``` + +- [ ] **Krok 4: Spustit testy a overit ze prochazeji** + +Run: +```bash +cd accbot-android && ./gradlew :app:testDebugUnitTest --tests "com.accbot.dca.domain.usecase.CalculatePlanCostBasisUseCaseTest" +``` + +Expected: 9 testu PASS. + +Pokud nejaky selhe, opravit implementaci, ne test (pokud test nepoukazuje na chybu zamerne). + +- [ ] **Krok 5: Build check** + +Run: +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Krok 6: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCase.kt +git add accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCaseTest.kt +git commit -m "feat(sell): add CalculatePlanCostBasisUseCase with timestamp-aware cheapest-first" +``` + +--- + +## Faze 2: Fee plumbing + +### Task 4: Pridat estimatedTakerFeeRate do ExchangeApi + +**Files:** +- Modify: `accbot-android/app/src/main/java/com/accbot/dca/exchange/ExchangeApi.kt` +- Modify: `accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt` +- Modify: `accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt` +- Modify: `accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt` +- Modify: `accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt` (Kraken, KuCoin, Bitfinex, Huobi) + +- [ ] **Krok 1: Pridat property do ExchangeApi interface** + +V `ExchangeApi.kt` interfejsu (poblize existujiciho `supportsLimitSell: Boolean`): + +```kotlin +/** + * Odhadovany taker fee rate (e.g. 0.0035 = 0.35%) pro decision support v UI. + * Hodnota nemusi presne odpovidat realnemu fee uzivatele (lower tier, BNB discount, ...). + */ +val estimatedTakerFeeRate: BigDecimal +``` + +- [ ] **Krok 2: Implementovat v CoinmateApi** + +V `CoinmateApi.kt`, doplnit pod existujici `takerFeeRate`: + +```kotlin +override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.0035") +``` + +(Existujici `private val takerFeeRate` se pouziva interne pro fallback fee - nezasahovat, je to jiny use case.) + +- [ ] **Krok 3: Implementovat v BinanceApi** + +V `BinanceApi.kt`: + +```kotlin +override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.001") +``` + +- [ ] **Krok 4: Implementovat v CoinbaseApi** + +V `CoinbaseApi.kt`: + +```kotlin +override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.0040") +``` + +- [ ] **Krok 5: Implementovat v Kraken/KuCoin/Bitfinex/Huobi (OtherExchanges.kt)** + +V kazde z trid: + +```kotlin +// KrakenApi +override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.0026") + +// KuCoinApi +override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.001") + +// BitfinexApi +override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.002") + +// HuobiApi +override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.002") +``` + +- [ ] **Krok 6: Build check** + +Run: +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +``` + +Expected: BUILD SUCCESSFUL. Pokud failne s "class is not abstract and does not implement abstract member estimatedTakerFeeRate" - chybi implementace v nektere z trid. + +- [ ] **Krok 7: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/exchange/ +git commit -m "feat(sell): add estimatedTakerFeeRate to ExchangeApi for fee math in wizard" +``` + +--- + +## Faze 3: Validation logic + +### Task 5: Pridat LossWarning do ValidateSellOrderUseCase + +**Files:** +- Modify: `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt` +- Create: `accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCaseLossTest.kt` + +- [ ] **Krok 1: Pridat LossWarning do sealed SellValidation** + +V `ValidateSellOrderUseCase.kt` v `sealed class SellValidation`: + +```kotlin +data class LossWarning(val lossFiat: BigDecimal, val lossPct: Double) : SellValidation() +``` + +- [ ] **Krok 2: Rozsirit invoke o avg buy price + fee rate vstupy** + +Modifikovat signature: + +```kotlin +suspend operator fun invoke( + planId: Long, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal, + minOrderSize: BigDecimal, + currentSpot: BigDecimal?, + avgBuyPrice: BigDecimal?, // NOVE + feeRate: BigDecimal // NOVE +): List +``` + +Pridat trigger logiku (umistit za existujici `instantFill` / `farFromMarket` checks, pred final `result.isEmpty()`): + +```kotlin +if (avgBuyPrice != null && avgBuyPrice > BigDecimal.ZERO) { + val grossFiat = cryptoAmount * limitPrice + val netFiat = grossFiat * (BigDecimal.ONE - feeRate) + val costBasis = cryptoAmount * avgBuyPrice + val netProfit = netFiat - costBasis + if (netProfit < BigDecimal.ZERO) { + val lossPct = if (costBasis > BigDecimal.ZERO) { + netProfit.toDouble() / costBasis.toDouble() + } else 0.0 + result += SellValidation.LossWarning(lossFiat = netProfit.negate(), lossPct = -lossPct) + } +} +``` + +- [ ] **Krok 3: Napsat unit testy pro novou logiku** + +`accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCaseLossTest.kt`: + +**Pozn.:** ValidateSellOrderUseCase pouziva `database` injekci. Pro unit test potrebujeme bud (a) refaktorovat loss-check do pure helperu, nebo (b) mockovat `DcaDatabase`. + +Refaktor: extrahovat loss-check do internal funkce nebo do companion objectu, testovat ji primo: + +V `ValidateSellOrderUseCase.kt` companion: + +```kotlin +companion object { + /** + * Pure helper pro loss-check, testovany unit testem. + * Vraci LossWarning kdyz `netProfit < 0`, jinak null. + */ + internal fun checkLoss( + cryptoAmount: BigDecimal, + limitPrice: BigDecimal, + avgBuyPrice: BigDecimal?, + feeRate: BigDecimal + ): SellValidation.LossWarning? { + if (avgBuyPrice == null || avgBuyPrice <= BigDecimal.ZERO) return null + val grossFiat = cryptoAmount * limitPrice + val netFiat = grossFiat * (BigDecimal.ONE - feeRate) + val costBasis = cryptoAmount * avgBuyPrice + val netProfit = netFiat - costBasis + if (netProfit >= BigDecimal.ZERO) return null + val lossPct = if (costBasis > BigDecimal.ZERO) { + netProfit.toDouble() / costBasis.toDouble() + } else 0.0 + return SellValidation.LossWarning(lossFiat = netProfit.negate(), lossPct = -lossPct) + } +} +``` + +A v `invoke()` zavolat `checkLoss(...)?.let { result += it }`. + +Test soubor: + +```kotlin +package com.accbot.dca.domain.usecase + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.math.BigDecimal + +class ValidateSellOrderUseCaseLossTest { + + @Test + fun `pod nakupni cenou vraci LossWarning`() { + val w = ValidateSellOrderUseCase.checkLoss( + cryptoAmount = BigDecimal("1"), + limitPrice = BigDecimal("900000"), + avgBuyPrice = BigDecimal("1000000"), + feeRate = BigDecimal("0.0035") + ) + assertEquals(true, w != null) + } + + @Test + fun `tesne nad nakupni cenou ale po fee ztrata vraci LossWarning`() { + // P=1003500, avg=1000000, fee=0.0035 + // grossFiat = 1003500, netFiat = 1003500 × 0.9965 = 999988.75 + // costBasis = 1000000 -> netProfit = -11.25 < 0 + val w = ValidateSellOrderUseCase.checkLoss( + cryptoAmount = BigDecimal("1"), + limitPrice = BigDecimal("1003500"), + avgBuyPrice = BigDecimal("1000000"), + feeRate = BigDecimal("0.0035") + ) + assertEquals(true, w != null) + } + + @Test + fun `dostatecne nad nakupni cenou vraci null`() { + val w = ValidateSellOrderUseCase.checkLoss( + cryptoAmount = BigDecimal("1"), + limitPrice = BigDecimal("1100000"), + avgBuyPrice = BigDecimal("1000000"), + feeRate = BigDecimal("0.0035") + ) + assertNull(w) + } + + @Test + fun `null avg vraci null`() { + val w = ValidateSellOrderUseCase.checkLoss( + cryptoAmount = BigDecimal("1"), + limitPrice = BigDecimal("900000"), + avgBuyPrice = null, + feeRate = BigDecimal("0.0035") + ) + assertNull(w) + } +} +``` + +- [ ] **Krok 4: Spustit testy** + +```bash +cd accbot-android && ./gradlew :app:testDebugUnitTest --tests "com.accbot.dca.domain.usecase.ValidateSellOrderUseCaseLossTest" +``` + +Expected: 4 testy PASS. + +- [ ] **Krok 5: Najit existujici call-sites ValidateSellOrderUseCase a pridat avg + feeRate parametry** + +Pravdepodobne jen `SellWizardViewModel.kt`. Hledat: + +```bash +grep -rn "validateSellOrderUseCase\|ValidateSellOrderUseCase" accbot-android/app/src/main +``` + +Doplnit volani s pravymi argumenty (avg z `CalculatePlanCostBasisUseCase`, feeRate z `api.estimatedTakerFeeRate`). Tohle se finalizuje az v Tasku 8, zatim staci aby kompilace prosla - lze docasne predat `null` a `BigDecimal.ZERO` a zustanou `LossWarning` skip vetve. + +- [ ] **Krok 6: Build check** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Krok 7: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt +git add accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCaseLossTest.kt +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt +git commit -m "feat(sell): LossWarning in ValidateSellOrderUseCase based on net-of-fee profit" +``` + +--- + +## Faze 4: Sell calculator helper a ViewModel + +### Task 6: SellCalculatorMath pure helper + testy + +**Files:** +- Create: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMath.kt` +- Create: `accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMathTest.kt` + +- [ ] **Krok 1: Implementovat pure helper** + +```kotlin +package com.accbot.dca.presentation.screens.plans.sell + +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Pure logika pro tripolovou kalkulacku (Amount, Price, Net). + * Vztah: N = A × P × (1 - feeRate) + * + * Pri editaci jednoho z poli ViewModel zavola [recompute] a zaznamena pole jako + * "naposledy editovane". Pole, ktere neni v `lastTwoEdited`, je dopocitano. + */ +object SellCalculatorMath { + + enum class Field { AMOUNT, PRICE, NET } + + /** + * @param a mnozstvi crypta + * @param p limit cena + * @param n cisty vynos + * @param feeRate burzy + * @param lastTwoEdited pole v poradi nejnovejsi -> druhe nejnovejsi (FIFO buffer) + * @return updated trojice (a, p, n) s dopocitanym 3. polem + */ + fun recompute( + a: BigDecimal?, + p: BigDecimal?, + n: BigDecimal?, + feeRate: BigDecimal, + lastTwoEdited: List + ): Triple { + if (lastTwoEdited.size < 2) return Triple(a, p, n) + val factor = BigDecimal.ONE - feeRate + val toCompute = Field.values().firstOrNull { it !in lastTwoEdited } ?: return Triple(a, p, n) + + return when (toCompute) { + Field.NET -> { + val newN = if (a != null && p != null) (a * p * factor).setScale(2, RoundingMode.HALF_UP) + else null + Triple(a, p, newN) + } + Field.PRICE -> { + val newP = if (a != null && n != null && a > BigDecimal.ZERO && factor > BigDecimal.ZERO) + n.divide(a * factor, 2, RoundingMode.HALF_UP) + else null + Triple(a, newP, n) + } + Field.AMOUNT -> { + val newA = if (p != null && n != null && p > BigDecimal.ZERO && factor > BigDecimal.ZERO) + n.divide(p * factor, 8, RoundingMode.HALF_UP) + else null + Triple(newA, p, n) + } + } + } +} +``` + +- [ ] **Krok 2: Napsat testy** + +```kotlin +package com.accbot.dca.presentation.screens.plans.sell + +import com.accbot.dca.presentation.screens.plans.sell.SellCalculatorMath.Field +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.math.BigDecimal + +class SellCalculatorMathTest { + + private val fee = BigDecimal("0.0035") // 0.35% + + @Test + fun `A a P editovane - dopocita N`() { + val (a, p, n) = SellCalculatorMath.recompute( + a = BigDecimal("1"), + p = BigDecimal("1000000"), + n = null, + feeRate = fee, + lastTwoEdited = listOf(Field.PRICE, Field.AMOUNT) + ) + // 1 × 1000000 × 0.9965 = 996500 + assertEquals(0, BigDecimal("996500.00").compareTo(n!!)) + assertEquals(BigDecimal("1"), a) + assertEquals(BigDecimal("1000000"), p) + } + + @Test + fun `A a N editovane - dopocita P`() { + val (a, p, n) = SellCalculatorMath.recompute( + a = BigDecimal("1"), + p = null, + n = BigDecimal("996500"), + feeRate = fee, + lastTwoEdited = listOf(Field.NET, Field.AMOUNT) + ) + // 996500 / (1 × 0.9965) = 1000000 + assertEquals(0, BigDecimal("1000000.00").compareTo(p!!)) + } + + @Test + fun `P a N editovane - dopocita A`() { + val (a, p, n) = SellCalculatorMath.recompute( + a = null, + p = BigDecimal("1000000"), + n = BigDecimal("996500"), + feeRate = fee, + lastTwoEdited = listOf(Field.NET, Field.PRICE) + ) + // 996500 / (1000000 × 0.9965) = 1.0 + assertEquals(0, BigDecimal("1.00000000").compareTo(a!!)) + } + + @Test + fun `mene nez 2 editovana pole - nedopocitava`() { + val (a, p, n) = SellCalculatorMath.recompute( + a = BigDecimal("1"), + p = null, + n = null, + feeRate = fee, + lastTwoEdited = listOf(Field.AMOUNT) + ) + assertNull(p) + assertNull(n) + } + + @Test + fun `chybejici vstupy v computed dvojici - vrati null`() { + val (a, p, n) = SellCalculatorMath.recompute( + a = null, + p = BigDecimal("1000000"), + n = null, + feeRate = fee, + lastTwoEdited = listOf(Field.PRICE, Field.AMOUNT) + ) + // computed = NET, ale a je null -> n = null + assertNull(n) + } +} +``` + +- [ ] **Krok 3: Spustit testy** + +```bash +cd accbot-android && ./gradlew :app:testDebugUnitTest --tests "com.accbot.dca.presentation.screens.plans.sell.SellCalculatorMathTest" +``` + +Expected: 5 testu PASS. + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMath.kt +git add accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMathTest.kt +git commit -m "feat(sell): SellCalculatorMath pure helper for amount/price/net field" +``` + +--- + +### Task 7: SellWizardViewModel - cost basis prefill + state machine + +**Files:** +- Modify: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt` + +- [ ] **Krok 1: Precist aktualni SellWizardViewModel** + +```bash +cat accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt | head -100 +``` + +Pochopit existujici state model. Identifikovat, kde se uchovava amount, limitPrice, kdy se vola `validateSellOrderUseCase` a `placeLimitSellUseCase`. + +- [ ] **Krok 2: Doplnit pole do state** + +V state data class (`SellWizardState` nebo podobne): + +```kotlin +data class SellWizardState( + // ...existujici pole... + val avgBuyPrice: String = "", // text input, prefill z cost basis + val avgBuyPriceManual: Boolean = false, // user prepsal default + val netFiat: String = "", // 3. pole kalkulacky + val computedRemainingInventory: RemainingInventory? = null, + val lastTwoEditedFields: List = emptyList(), + val feeRate: BigDecimal = BigDecimal.ZERO, // z api.estimatedTakerFeeRate + val lossWarning: SellValidation.LossWarning? = null +) +``` + +- [ ] **Krok 3: Pridat dependencies do constructoru** + +```kotlin +@HiltViewModel +class SellWizardViewModel @Inject constructor( + private val savedState: SavedStateHandle, // pokud uz neni + private val database: DcaDatabase, // existing + private val calculatePlanCostBasisUseCase: CalculatePlanCostBasisUseCase, // NEW + private val exchangeApiFactory: ExchangeApiFactory, // existing or new + private val credentialsStore: CredentialsStore, + private val userPreferences: UserPreferences, + private val validateSellOrderUseCase: ValidateSellOrderUseCase, + private val placeLimitSellUseCase: PlaceLimitSellUseCase, + private val minOrderSizeRepository: MinOrderSizeRepository +) : ViewModel() { ... } +``` + +- [ ] **Krok 4: Pri inicializaci viewmodelu (load planId) spustit cost basis a fee rate fetch** + +```kotlin +init { + val planId = savedState.get("planId") ?: return + viewModelScope.launch { + val plan = database.dcaPlanDao().getPlanById(planId) ?: return@launch + val credentials = credentialsStore.getCredentials( + plan.connectionId, userPreferences.isSandboxMode() + ) ?: return@launch + val api = exchangeApiFactory.create(credentials) + + val inventory = calculatePlanCostBasisUseCase(planId) + _state.update { + it.copy( + computedRemainingInventory = inventory, + avgBuyPrice = inventory.weightedAvgPrice?.toPlainString() ?: "", + feeRate = api.estimatedTakerFeeRate + ) + } + } +} +``` + +- [ ] **Krok 5: Reagovat na editaci poli (amount/price/net)** + +Pridat handlery: + +```kotlin +fun onAmountChange(text: String) { + _state.update { st -> + val a = text.toBigDecimalOrNull() + val p = st.limitPrice.toBigDecimalOrNull() + val n = st.netFiat.toBigDecimalOrNull() + val newLastTwo = listOf(SellCalculatorMath.Field.AMOUNT) + + st.lastTwoEditedFields.filter { it != SellCalculatorMath.Field.AMOUNT }.take(1) + val (newA, newP, newN) = SellCalculatorMath.recompute(a, p, n, st.feeRate, newLastTwo) + st.copy( + amount = text, + limitPrice = newP?.toPlainString() ?: st.limitPrice, + netFiat = newN?.toPlainString() ?: st.netFiat, + lastTwoEditedFields = newLastTwo + ) + } + revalidate() +} + +fun onLimitPriceChange(text: String) { + _state.update { st -> + val a = st.amount.toBigDecimalOrNull() + val p = text.toBigDecimalOrNull() + val n = st.netFiat.toBigDecimalOrNull() + val newLastTwo = listOf(SellCalculatorMath.Field.PRICE) + + st.lastTwoEditedFields.filter { it != SellCalculatorMath.Field.PRICE }.take(1) + val (newA, newP, newN) = SellCalculatorMath.recompute(a, p, n, st.feeRate, newLastTwo) + st.copy( + amount = newA?.toPlainString() ?: st.amount, + limitPrice = text, + netFiat = newN?.toPlainString() ?: st.netFiat, + lastTwoEditedFields = newLastTwo + ) + } + revalidate() +} + +fun onNetFiatChange(text: String) { + _state.update { st -> + val a = st.amount.toBigDecimalOrNull() + val p = st.limitPrice.toBigDecimalOrNull() + val n = text.toBigDecimalOrNull() + val newLastTwo = listOf(SellCalculatorMath.Field.NET) + + st.lastTwoEditedFields.filter { it != SellCalculatorMath.Field.NET }.take(1) + val (newA, newP, newN) = SellCalculatorMath.recompute(a, p, n, st.feeRate, newLastTwo) + st.copy( + amount = newA?.toPlainString() ?: st.amount, + limitPrice = newP?.toPlainString() ?: st.limitPrice, + netFiat = text, + lastTwoEditedFields = newLastTwo + ) + } + revalidate() +} +``` + +- [ ] **Krok 6: Manualni override avg buy** + +```kotlin +fun onAvgBuyPriceChange(text: String) { + _state.update { it.copy(avgBuyPrice = text, avgBuyPriceManual = text.isNotBlank()) } + revalidate() +} + +fun onResetAvgBuyPrice() { + _state.update { st -> + st.copy( + avgBuyPrice = st.computedRemainingInventory?.weightedAvgPrice?.toPlainString() ?: "", + avgBuyPriceManual = false + ) + } + revalidate() +} +``` + +- [ ] **Krok 7: Aktualizovat revalidate s avgBuy a feeRate** + +```kotlin +private fun revalidate() { + viewModelScope.launch { + val st = _state.value + val avg = st.avgBuyPrice.toBigDecimalOrNull() + val amount = st.amount.toBigDecimalOrNull() + val price = st.limitPrice.toBigDecimalOrNull() + if (amount == null || price == null) return@launch + + val results = validateSellOrderUseCase( + planId = st.planId, + cryptoAmount = amount, + limitPrice = price, + minOrderSize = st.minOrderSize ?: BigDecimal.ZERO, + currentSpot = st.currentSpot, + avgBuyPrice = avg, + feeRate = st.feeRate + ) + + val loss = results.filterIsInstance().firstOrNull() + _state.update { it.copy(validation = results, lossWarning = loss) } + } +} +``` + +- [ ] **Krok 8: Build check** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +``` + +Expected: BUILD SUCCESSFUL. Pokud je tam neco co konfliktuje s aktualni state, najit & opravit; struktura ViewModelu je popsana obecne, sladit s realnou. + +- [ ] **Krok 9: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt +git commit -m "feat(sell): wire cost basis + 3-field calculator into SellWizardViewModel" +``` + +--- + +## Faze 5: UI - single mod + +### Task 8: Avg buy price field + reset tlacitko + +**Files:** +- Modify: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt` +- Modify: `accbot-android/app/src/main/res/values/strings.xml` +- Modify: `accbot-android/app/src/main/res/values-cs/strings.xml` + +- [ ] **Krok 1: Pridat stringy** + +`values/strings.xml`: + +```xml +Average buy price +Auto-calculated from this plan +Manually entered +Calculate from plan +Enter manually (no buys yet or all sold) +``` + +`values-cs/strings.xml`: + +```xml +Prumerna nakupni cena +Spocitano z planu +Zadano rucne +Spocitat z planu +Zadej rucne (zadne buys nebo vse prodano) +``` + +- [ ] **Krok 2: Pridat OutlinedTextField pro avg buy price (uvnitr SellWizardBottomSheet, krok 1)** + +Najit zacatek wizardu (Composable function `SellWizardStep1` nebo podobne) a pridat na zacatek, pred existujici pole pro mnozstvi: + +```kotlin +OutlinedTextField( + value = state.avgBuyPrice, + onValueChange = viewModel::onAvgBuyPriceChange, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.sell_wizard_avg_buy_price)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + supportingText = { + val res = when { + state.computedRemainingInventory?.weightedAvgPrice == null -> R.string.sell_wizard_avg_buy_price_required + state.avgBuyPriceManual -> R.string.sell_wizard_avg_buy_price_helper_manual + else -> R.string.sell_wizard_avg_buy_price_helper_auto + } + Text(stringResource(res)) + }, + trailingIcon = { + if (state.avgBuyPriceManual && state.computedRemainingInventory?.weightedAvgPrice != null) { + TextButton(onClick = viewModel::onResetAvgBuyPrice) { + Text(stringResource(R.string.sell_wizard_avg_buy_price_reset)) + } + } + } +) +``` + +- [ ] **Krok 3: Build check** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +``` + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +git add accbot-android/app/src/main/res/values/strings.xml +git add accbot-android/app/src/main/res/values-cs/strings.xml +git commit -m "feat(sell): avg buy price field with auto-prefill + manual override" +``` + +--- + +### Task 9: Net fiat pole + presety + +**Files:** +- Modify: `SellWizardBottomSheet.kt` +- Modify: `strings.xml` (cs + en) + +- [ ] **Krok 1: Stringy** + +```xml + +Net proceeds (after fee) +Profit on this transaction ++10% ++20% ++50% ++100% + + +Cisty vynos (po fee) +Zisk na transakci + +``` + +- [ ] **Krok 2: Pridat OutlinedTextField + preset Row pod limit price field** + +```kotlin +OutlinedTextField( + value = state.netFiat, + onValueChange = viewModel::onNetFiatChange, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.sell_wizard_net_fiat)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal) +) + +Text( + stringResource(R.string.sell_wizard_net_preset_label), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant +) +Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + listOf(0.10 to R.string.sell_wizard_net_preset_10, + 0.20 to R.string.sell_wizard_net_preset_20, + 0.50 to R.string.sell_wizard_net_preset_50, + 1.00 to R.string.sell_wizard_net_preset_100).forEach { (factor, label) -> + FilterChip( + selected = false, + onClick = { viewModel.applyNetPreset(factor) }, + label = { Text(stringResource(label)) } + ) + } +} +``` + +- [ ] **Krok 3: ViewModel handler** + +V `SellWizardViewModel`: + +```kotlin +fun applyNetPreset(profitTarget: Double) { + val st = _state.value + val a = st.amount.toBigDecimalOrNull() ?: return + val avg = st.avgBuyPrice.toBigDecimalOrNull() ?: return + if (avg <= BigDecimal.ZERO || a <= BigDecimal.ZERO) return + val target = a * avg * (BigDecimal.ONE + BigDecimal(profitTarget)) + onNetFiatChange(target.setScale(2, RoundingMode.HALF_UP).toPlainString()) +} +``` + +- [ ] **Krok 4: Build a commit** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/ +git add accbot-android/app/src/main/res/values/strings.xml +git add accbot-android/app/src/main/res/values-cs/strings.xml +git commit -m "feat(sell): net proceeds field + profit-target presets" +``` + +--- + +### Task 10: Cenove presety dropdown (% z avg / % ze spotu) + +**Files:** +- Modify: `SellWizardBottomSheet.kt` +- Modify: `SellWizardViewModel.kt` +- Modify: `strings.xml` (cs + en) + +- [ ] **Krok 1: Stringy** + +```xml + +% above avg buy +% above spot + + +% z avg buy +% ze spotu +``` + +- [ ] **Krok 2: ViewModel state + handlery** + +V `SellWizardState`: + +```kotlin +val priceP resetMode: PricePresetMode = PricePresetMode.AVG_BUY, + +enum class PricePresetMode { AVG_BUY, SPOT } +``` + +Handlery: + +```kotlin +fun onPricePresetModeChange(mode: PricePresetMode) { + _state.update { it.copy(pricePresetMode = mode) } +} + +fun applyPricePreset(factor: Double) { + val st = _state.value + val basis = when (st.pricePresetMode) { + PricePresetMode.AVG_BUY -> st.avgBuyPrice.toBigDecimalOrNull() + PricePresetMode.SPOT -> st.currentSpot + } ?: return + val newPrice = basis * (BigDecimal.ONE + BigDecimal(factor)) + onLimitPriceChange(newPrice.setScale(2, RoundingMode.HALF_UP).toPlainString()) +} +``` + +- [ ] **Krok 3: UI - DropdownMenu nahore presetu** + +```kotlin +var modeMenuOpen by remember { mutableStateOf(false) } +Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(when (state.pricePresetMode) { + PricePresetMode.AVG_BUY -> R.string.sell_wizard_price_preset_mode_avg + PricePresetMode.SPOT -> R.string.sell_wizard_price_preset_mode_spot + }), + modifier = Modifier.clickable { modeMenuOpen = true } + ) + Icon(Icons.Filled.ArrowDropDown, contentDescription = null) + DropdownMenu(expanded = modeMenuOpen, onDismissRequest = { modeMenuOpen = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.sell_wizard_price_preset_mode_avg)) }, + onClick = { viewModel.onPricePresetModeChange(PricePresetMode.AVG_BUY); modeMenuOpen = false } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.sell_wizard_price_preset_mode_spot)) }, + onClick = { viewModel.onPricePresetModeChange(PricePresetMode.SPOT); modeMenuOpen = false } + ) + } +} + +Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + listOf(0.05, 0.10, 0.20, 0.50).forEach { factor -> + FilterChip( + selected = false, + onClick = { viewModel.applyPricePreset(factor) }, + label = { Text("+${(factor * 100).toInt()}%") } + ) + } +} +``` + +- [ ] **Krok 4: Build a commit** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/ +git add accbot-android/app/src/main/res/values/strings.xml +git add accbot-android/app/src/main/res/values-cs/strings.xml +git commit -m "feat(sell): price presets with avg-buy/spot mode toggle" +``` + +--- + +### Task 11: Summary - profit per coin, fee, cisty zisk, remaining avg, postup k cili + +**Files:** +- Modify: `SellWizardBottomSheet.kt` (summary sekce) +- Modify: `SellWizardViewModel.kt` (computed summary state) +- Modify: `strings.xml` + +- [ ] **Krok 1: Pridat computed summary do state** + +```kotlin +data class SellSummary( + val profitPerCoin: BigDecimal? = null, + val grossProfit: BigDecimal? = null, + val estimatedFee: BigDecimal? = null, + val netProfit: BigDecimal? = null, + val netProfitPct: Double? = null, + val remainingAfter: RemainingInventory? = null, + val targetProgressPct: Double? = null +) + +val summary: SellSummary = SellSummary() +``` + +- [ ] **Krok 2: Pridat computeSummary v ViewModelu po revalidate** + +```kotlin +private fun computeSummary() { + val st = _state.value + val a = st.amount.toBigDecimalOrNull() + val p = st.limitPrice.toBigDecimalOrNull() + val avg = st.avgBuyPrice.toBigDecimalOrNull() + if (a == null || p == null || avg == null || a <= BigDecimal.ZERO) { + _state.update { it.copy(summary = SellSummary()) } + return + } + val factor = BigDecimal.ONE - st.feeRate + val grossFiat = a * p + val estimatedFee = grossFiat * st.feeRate + val netFiat = grossFiat * factor + val costBasis = a * avg + val grossProfit = grossFiat - costBasis + val netProfit = netFiat - costBasis + val netProfitPct = if (costBasis > BigDecimal.ZERO) netProfit.toDouble() / costBasis.toDouble() else 0.0 + + // hypoteticky pridat ten sell mezi historicke a prepocitat remaining + val remaining = computeRemainingAfterHypotheticalSell(st.planId, a, p) + + // postup k cili + val plan = st.plan + val target = plan?.targetProfitAmount + val realizedSoFar = computeRealizedPnLSoFar(st.planId) + val progress = if (target != null && target > BigDecimal.ZERO) + (realizedSoFar + netProfit).toDouble() / target.toDouble() + else null + + _state.update { + it.copy(summary = SellSummary( + profitPerCoin = p - avg, + grossProfit = grossProfit, + estimatedFee = estimatedFee, + netProfit = netProfit, + netProfitPct = netProfitPct, + remainingAfter = remaining, + targetProgressPct = progress + )) + } +} +``` + +`computeRemainingAfterHypotheticalSell` - vlozit fake SELL transakci do listu transakci a zavolat `CalculatePlanCostBasisUseCase.computeCostBasis(...)`: + +```kotlin +private suspend fun computeRemainingAfterHypotheticalSell( + planId: Long, + cryptoAmount: BigDecimal, + price: BigDecimal +): RemainingInventory { + val txs = database.transactionDao().getTransactionsByPlanSync(planId) + val fakeSell = TransactionEntity( + id = -1, planId = planId, connectionId = 0, + exchange = Exchange.COINMATE, crypto = "?", fiat = "?", + fiatAmount = cryptoAmount * price, + cryptoAmount = cryptoAmount, + price = price, fee = BigDecimal.ZERO, feeAsset = "", + status = TransactionStatus.COMPLETED, + exchangeOrderId = "fake", + executedAt = Instant.now(), + side = TransactionSide.SELL, + requestedCryptoAmount = cryptoAmount, + limitPrice = price + ) + return CalculatePlanCostBasisUseCase.computeCostBasis(txs + fakeSell) +} +``` + +`computeRealizedPnLSoFar` - pouzit existujici `CalculatePlanPnLUseCase` (lifetime accounting), ktery uz mame: + +```kotlin +private suspend fun computeRealizedPnLSoFar(planId: Long): BigDecimal { + return calculatePlanPnLUseCase(planId, currentMarketPrice = null).realizedPnL + ?: BigDecimal.ZERO +} +``` + +(Pridat `CalculatePlanPnLUseCase` do constructor injection pokud tam neni.) + +- [ ] **Krok 3: UI - Summary sekce** + +```kotlin +@Composable +fun SellSummarySection( + summary: SellSummary, + lossWarning: SellValidation.LossWarning?, + target: BigDecimal?, + fiat: String +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.sell_wizard_summary_title), style = MaterialTheme.typography.titleSmall) + + SummaryRow(stringResource(R.string.sell_wizard_summary_profit_per_coin), summary.profitPerCoin, fiat) + SummaryRow(stringResource(R.string.sell_wizard_summary_gross_profit), summary.grossProfit, fiat) + SummaryRow(stringResource(R.string.sell_wizard_summary_fee), summary.estimatedFee?.negate(), fiat) + SummaryRow( + label = stringResource(R.string.sell_wizard_summary_net_profit), + value = summary.netProfit, + fiat = fiat, + highlightLoss = (summary.netProfit?.signum() ?: 0) < 0, + extra = summary.netProfitPct?.let { " (${"%+.1f".format(it * 100)}%)" } + ) + summary.remainingAfter?.let { ri -> + val avgText = ri.weightedAvgPrice?.setScale(2, RoundingMode.HALF_UP)?.toPlainString() ?: "-" + Text( + stringResource(R.string.sell_wizard_summary_remaining_after) + + ": ${ri.available.setScale(8, RoundingMode.DOWN).stripTrailingZeros().toPlainString()} @ $avgText" + ) + } + if (target != null && summary.targetProgressPct != null) { + Text( + stringResource(R.string.sell_wizard_summary_target_progress) + + ": ${"%.0f".format(summary.targetProgressPct * 100)}%" + ) + LinearProgressIndicator( + progress = { summary.targetProgressPct.toFloat().coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +private fun SummaryRow( + label: String, + value: BigDecimal?, + fiat: String, + highlightLoss: Boolean = false, + extra: String? = null +) { + if (value == null) return + val color = if (highlightLoss) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurface + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + "${value.setScale(2, RoundingMode.HALF_UP).toPlainString()} $fiat${extra ?: ""}", + color = color + ) + } +} +``` + +- [ ] **Krok 4: Stringy** + +```xml + +Summary +Profit per coin +Gross profit +Estimated fee +Net profit (after fee) +After this sell +Plan target progress + + +Souhrn +Zisk na coin +Hruby zisk +Odhad fee +Cisty zisk (po fee) +Po prodeji +Postup k cili planu +``` + +- [ ] **Krok 5: Build a commit** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +git add ... +git commit -m "feat(sell): rich summary with profit, fee, remaining inventory, target progress" +``` + +--- + +### Task 12: Loss warning banner + +**Files:** +- Modify: `SellWizardBottomSheet.kt` +- Modify: `strings.xml` + +- [ ] **Krok 1: Stringy** + +```xml + +You are selling below your buy price: %1$s +After fee, you are selling at a loss: %1$s + + +Prodavas pod nakupni cenou: %1$s +Po fee prodavas se ztratou: %1$s +``` + +- [ ] **Krok 2: Banner v UI mezi summary a tlacitkem Pokracovat** + +```kotlin +state.lossWarning?.let { warn -> + val limitPrice = state.limitPrice.toBigDecimalOrNull() + val avg = state.avgBuyPrice.toBigDecimalOrNull() + val isPriceBelowAvg = limitPrice != null && avg != null && limitPrice < avg + val resId = if (isPriceBelowAvg) R.string.sell_wizard_loss_below_buy + else R.string.sell_wizard_loss_after_fee + Surface( + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth().padding(8.dp) + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Warning, contentDescription = null, + tint = MaterialTheme.colorScheme.error) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(resId, formatFiat(warn.lossFiat, state.fiat)), + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } +} +``` + +- [ ] **Krok 3: Build a commit** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +git add ... +git commit -m "feat(sell): loss warning banner triggered by net-profit < 0" +``` + +--- + +### Task 13: Manualni overeni single mod konci-konce + +Bez code zmen. Otevrit appku v emulatoru / fyzickem zarizeni: + +- [ ] **Krok 1: Build debug APK** + +```bash +cd accbot-android && ./gradlew :app:assembleDebug +``` + +- [ ] **Krok 2: Nainstalovat na zarizeni** + +```bash +adb install -r accbot-android/app/build/outputs/apk/debug/app-debug.apk +``` + +- [ ] **Krok 3: Scenare pro overeni single modu** + +- **Plan s buys**: otevrit sell wizard, avg pole prefilled. Zkusit zmenit, reset pres tlacitko. Vrati se k auto. +- **Prazdny plan (zadne buys)**: otevrit sell wizard, avg pole prazdne, helper text "Zadej rucne". Vyplnit, validace projde. +- **Editovat A a P**: N se dopocita. Editovat A a N: P se dopocita. Editovat P a N: A se dopocita. +- **Cenove presety**: AVG mode, +10% -> P = avg × 1.10. Prepnout na SPOT mode, +10% -> P = spot × 1.10. +- **Net presety**: +20% -> N = A × avg × 1.20. +- **Loss case**: zadat P = avg - 1, banner "Prodavas pod nakupni cenou". Zadat P tesne nad avg (~ 0.3% nad), banner "Po fee se ztratou". +- **Summary**: vsechny radky prochazeji, profit cervene pri ztrate, target progress jen pokud `targetProfitAmount` na planu nastaveno. + +- [ ] **Krok 4: Pripadne opravy** + +Pokud nektery scenar selhe, opravit konkretni bug. Maly commit `fix(sell): ...`. + +--- + +## Faze 6: Ladder mode + +### Task 14: PlaceLadderSellUseCase + +**Files:** +- Create: `accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt` + +- [ ] **Krok 1: Implementovat use case** + +```kotlin +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.data.local.toEntity +import com.accbot.dca.domain.model.DcaResult +import com.accbot.dca.exchange.ExchangeApiFactory +import java.math.BigDecimal +import javax.inject.Inject + +data class LadderOrder(val cryptoAmount: BigDecimal, val limitPrice: BigDecimal) + +sealed class LadderResult { + data class AllPlaced(val placedTxIds: List) : LadderResult() + data class PartialFailure( + val placedTxIds: List, + val failedAtIndex: Int, + val totalCount: Int, + val reason: String + ) : LadderResult() +} + +class PlaceLadderSellUseCase @Inject constructor( + private val database: DcaDatabase, + private val credentialsStore: CredentialsStore, + private val exchangeApiFactory: ExchangeApiFactory, + private val userPreferences: UserPreferences, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase +) { + suspend operator fun invoke( + planId: Long, + orders: List + ): LadderResult { + if (orders.size < 2) return LadderResult.PartialFailure( + emptyList(), 0, orders.size, "Ladder vyzaduje aspon 2 ordery" + ) + + val plan = database.dcaPlanDao().getPlanById(planId) + ?: return LadderResult.PartialFailure(emptyList(), 0, orders.size, "Plan neexistuje") + val credentials = credentialsStore.getCredentials( + plan.connectionId, userPreferences.isSandboxMode() + ) ?: return LadderResult.PartialFailure(emptyList(), 0, orders.size, "Chybi credentials") + + val api = exchangeApiFactory.create(credentials) + val placed = mutableListOf() + + orders.forEachIndexed { idx, order -> + val result = api.limitSell(plan.crypto, plan.fiat, order.cryptoAmount, order.limitPrice) + when (result) { + is DcaResult.Success -> { + val tx = result.transaction.copy(planId = planId, connectionId = plan.connectionId) + val id = database.transactionDao().insertTransaction(tx.toEntity()) + placed += id + } + is DcaResult.Error -> { + return LadderResult.PartialFailure(placed, idx, orders.size, result.message) + } + } + } + + try { resolvePendingTransactionsUseCase() } catch (_: Exception) {} + return LadderResult.AllPlaced(placed) + } +} +``` + +- [ ] **Krok 2: Build check** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +``` + +- [ ] **Krok 3: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt +git commit -m "feat(sell): PlaceLadderSellUseCase with stop-and-report failure handling" +``` + +--- + +### Task 15: Ladder generator helper + testy + +**Files:** +- Create: `accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/LadderGenerator.kt` +- Create: `accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/LadderGeneratorTest.kt` + +- [ ] **Krok 1: Implementovat generator** + +```kotlin +package com.accbot.dca.presentation.screens.plans.sell + +import com.accbot.dca.domain.usecase.LadderOrder +import java.math.BigDecimal +import java.math.RoundingMode + +object LadderGenerator { + + enum class AmountMode { EQUAL_CRYPTO, EQUAL_FIAT } + + /** + * Generuje N orderu s linearne rozprostrenymi cenami od `from` do `to`. + * @param totalAmount celkove crypto k prodeji + * @param mode rozdeleni mezi ordery + */ + fun generate( + totalAmount: BigDecimal, + from: BigDecimal, + to: BigDecimal, + count: Int, + mode: AmountMode + ): List { + require(count >= 2) { "count >= 2" } + require(totalAmount > BigDecimal.ZERO) { "totalAmount > 0" } + require(from > BigDecimal.ZERO && to > BigDecimal.ZERO) { "ceny > 0" } + + val prices = (0 until count).map { i -> + from + (to - from) * BigDecimal(i) / BigDecimal(count - 1) + } + + return when (mode) { + AmountMode.EQUAL_CRYPTO -> { + val per = totalAmount.divide(BigDecimal(count), 8, RoundingMode.DOWN) + // Korekce zaokrouhleni na poslednim orderu (zbyle drobky pridat) + val drobky = totalAmount - per * BigDecimal(count) + prices.mapIndexed { i, p -> + val a = if (i == count - 1) per + drobky else per + LadderOrder(a, p.setScale(2, RoundingMode.HALF_UP)) + } + } + AmountMode.EQUAL_FIAT -> { + val totalGross = (prices.sum()) * totalAmount / BigDecimal(count) // pro odhad equal fiat + // Equal fiat: kazdy order vygeneruje stejny gross fiat (totalGross / N) + // amount_i = (totalGross / N) / price_i + val perOrderFiat = totalGross.divide(BigDecimal(count), 8, RoundingMode.HALF_UP) + val amounts = prices.map { p -> + perOrderFiat.divide(p, 8, RoundingMode.DOWN) + } + val sumAmounts = amounts.fold(BigDecimal.ZERO) { acc, x -> acc + x } + // Skalovat aby suma sedela na totalAmount + val scale = if (sumAmounts > BigDecimal.ZERO) totalAmount.divide(sumAmounts, 8, RoundingMode.HALF_UP) + else BigDecimal.ONE + amounts.mapIndexed { i, a -> + val scaled = (a * scale).setScale(8, RoundingMode.DOWN) + LadderOrder(scaled, prices[i].setScale(2, RoundingMode.HALF_UP)) + } + } + } + } + + private fun List.sum(): BigDecimal = fold(BigDecimal.ZERO) { acc, x -> acc + x } +} +``` + +- [ ] **Krok 2: Testy** + +```kotlin +package com.accbot.dca.presentation.screens.plans.sell + +import com.accbot.dca.presentation.screens.plans.sell.LadderGenerator.AmountMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.math.BigDecimal + +class LadderGeneratorTest { + + @Test + fun `equal crypto - 5 orderu po 0,2 BTC v rozsahu cen`() { + val orders = LadderGenerator.generate( + totalAmount = BigDecimal("1"), + from = BigDecimal("2000000"), + to = BigDecimal("2400000"), + count = 5, + mode = AmountMode.EQUAL_CRYPTO + ) + assertEquals(5, orders.size) + // ceny linearne: 2.0M, 2.1M, 2.2M, 2.3M, 2.4M + assertEquals(0, BigDecimal("2000000.00").compareTo(orders[0].limitPrice)) + assertEquals(0, BigDecimal("2400000.00").compareTo(orders[4].limitPrice)) + // mnozstvi: kazdy 0.2 BTC, suma = 1 + val total = orders.fold(BigDecimal.ZERO) { acc, o -> acc + o.cryptoAmount } + assertEquals(0, BigDecimal("1.00000000").compareTo(total)) + } + + @Test + fun `equal fiat - levnejsi ordery prodavaji vic crypta`() { + val orders = LadderGenerator.generate( + totalAmount = BigDecimal("1"), + from = BigDecimal("1000000"), + to = BigDecimal("2000000"), + count = 4, + mode = AmountMode.EQUAL_FIAT + ) + assertEquals(4, orders.size) + // levnejsi (index 0) -> vetsi mnozstvi + assertTrue(orders[0].cryptoAmount > orders[3].cryptoAmount) + } + + @Test(expected = IllegalArgumentException::class) + fun `count menez nez 2 hodi exception`() { + LadderGenerator.generate(BigDecimal("1"), BigDecimal("1000"), BigDecimal("2000"), 1, AmountMode.EQUAL_CRYPTO) + } +} +``` + +- [ ] **Krok 3: Spustit testy** + +```bash +cd accbot-android && ./gradlew :app:testDebugUnitTest --tests "com.accbot.dca.presentation.screens.plans.sell.LadderGeneratorTest" +``` + +Expected: 3 testy PASS. + +- [ ] **Krok 4: Commit** + +```bash +git add accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/LadderGenerator.kt +git add accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/LadderGeneratorTest.kt +git commit -m "feat(sell): LadderGenerator pure helper for linear scale-out" +``` + +--- + +### Task 16: Ladder validation v ValidateSellOrderUseCase + +**Files:** +- Modify: `ValidateSellOrderUseCase.kt` + +- [ ] **Krok 1: Pridat ladder validate metodu** + +V tride pridat: + +```kotlin +suspend fun validateLadder( + planId: Long, + orders: List, + minOrderSize: BigDecimal, + avgBuyPrice: BigDecimal?, + feeRate: BigDecimal, + currentSpot: BigDecimal? +): List { + val total = orders.fold(BigDecimal.ZERO) { acc, o -> acc + o.cryptoAmount } + val baseValidation = invoke( + planId = planId, + cryptoAmount = total, + limitPrice = orders.firstOrNull()?.limitPrice ?: BigDecimal.ONE, // proxy pro available check + minOrderSize = orders.minOf { it.cryptoAmount }, + currentSpot = null, // skip instant-fill / far-from-market u ladderu + avgBuyPrice = avgBuyPrice, + feeRate = feeRate + ).filter { + // Vyhodit instant-fill / far-from-market warningy z proxy validace + it !is SellValidation.InstantFillInfo && it !is SellValidation.FarFromMarketWarning + }.toMutableList() + + // Aggregated loss warning + val totalLoss = orders.fold(BigDecimal.ZERO) { acc, o -> + val l = checkLoss(o.cryptoAmount, o.limitPrice, avgBuyPrice, feeRate)?.lossFiat ?: BigDecimal.ZERO + acc + l + } + if (totalLoss > BigDecimal.ZERO) { + baseValidation += SellValidation.LossWarning(totalLoss, 0.0) + } + + // Per-order minOrderSize check je uz v invoke pres `minOf` - OK + // Far-from-market: kdyz prvni cena (nejnizsi) > 3 × spot + if (currentSpot != null && orders.first().limitPrice > currentSpot * BigDecimal("3")) { + baseValidation += SellValidation.FarFromMarketWarning(currentSpot) + } + + return baseValidation.ifEmpty { listOf(SellValidation.Ok) } +} +``` + +- [ ] **Krok 2: Build a commit** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +git add accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt +git commit -m "feat(sell): ladder validation with aggregated loss + edge checks" +``` + +--- + +### Task 17: SellWizardViewModel - ladder state + +**Files:** +- Modify: `SellWizardViewModel.kt` + +- [ ] **Krok 1: Rozsirit state** + +```kotlin +data class SellWizardState( + // ... + val ladderEnabled: Boolean = false, + val ladderRangeMode: LadderRangeMode = LadderRangeMode.PRICE, + val ladderFrom: String = "", + val ladderTo: String = "", + val ladderCount: String = "5", + val ladderAmountMode: LadderGenerator.AmountMode = LadderGenerator.AmountMode.EQUAL_CRYPTO, + val ladderPreview: List = emptyList() +) + +enum class LadderRangeMode { PRICE, PROFIT_PCT } +``` + +- [ ] **Krok 2: Pridat ladder handlery** + +```kotlin +fun onLadderEnabledChange(enabled: Boolean) { + _state.update { it.copy(ladderEnabled = enabled) } + recomputeLadderPreview() +} + +fun onLadderRangeModeChange(mode: LadderRangeMode) { + _state.update { it.copy(ladderRangeMode = mode) } + recomputeLadderPreview() +} + +fun onLadderFromChange(text: String) { + _state.update { it.copy(ladderFrom = text) } + recomputeLadderPreview() +} + +fun onLadderToChange(text: String) { + _state.update { it.copy(ladderTo = text) } + recomputeLadderPreview() +} + +fun onLadderCountChange(text: String) { + _state.update { it.copy(ladderCount = text) } + recomputeLadderPreview() +} + +fun onLadderAmountModeChange(mode: LadderGenerator.AmountMode) { + _state.update { it.copy(ladderAmountMode = mode) } + recomputeLadderPreview() +} + +private fun recomputeLadderPreview() { + val st = _state.value + if (!st.ladderEnabled) { + _state.update { it.copy(ladderPreview = emptyList()) } + return + } + val total = st.amount.toBigDecimalOrNull() ?: return + val count = st.ladderCount.toIntOrNull() ?: return + if (count < 2) return + + val avg = st.avgBuyPrice.toBigDecimalOrNull() + val (from, to) = when (st.ladderRangeMode) { + LadderRangeMode.PRICE -> { + val f = st.ladderFrom.toBigDecimalOrNull() ?: return + val t = st.ladderTo.toBigDecimalOrNull() ?: return + f to t + } + LadderRangeMode.PROFIT_PCT -> { + if (avg == null) return + val fPct = st.ladderFrom.toBigDecimalOrNull() ?: return + val tPct = st.ladderTo.toBigDecimalOrNull() ?: return + (avg * (BigDecimal.ONE + fPct / BigDecimal("100"))) to + (avg * (BigDecimal.ONE + tPct / BigDecimal("100"))) + } + } + if (to <= from) return + + val orders = LadderGenerator.generate(total, from, to, count, st.ladderAmountMode) + _state.update { it.copy(ladderPreview = orders) } +} +``` + +- [ ] **Krok 3: Build a commit** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +git add ... +git commit -m "feat(sell): ladder state machine in SellWizardViewModel" +``` + +--- + +### Task 18: Ladder UI - checkbox, range, count, toggles, preview tabulka + +**Files:** +- Modify: `SellWizardBottomSheet.kt` +- Modify: `strings.xml` + +- [ ] **Krok 1: Stringy** + +```xml + +Create multiple sell orders +From +To +Number of orders +Price +Profit % +Equal crypto +Equal fiat +Total at full fill + + +Vytvorit vice sell orderu +Od +Do +Pocet orderu +Cena +Profit % +Stejne crypto +Stejny fiat +Celkem pri plnem fillu +``` + +- [ ] **Krok 2: UI - checkbox + ladder block (visible jen kdyz enabled)** + +```kotlin +Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = state.ladderEnabled, + onCheckedChange = viewModel::onLadderEnabledChange + ) + Text(stringResource(R.string.sell_wizard_ladder_enable)) +} + +if (state.ladderEnabled) { + // Range mode toggle (Cena / Profit %) + SegmentedButton( + options = listOf( + LadderRangeMode.PRICE to stringResource(R.string.sell_wizard_ladder_range_price), + LadderRangeMode.PROFIT_PCT to stringResource(R.string.sell_wizard_ladder_range_profit) + ), + selected = state.ladderRangeMode, + onSelect = viewModel::onLadderRangeModeChange + ) + + Row { + OutlinedTextField( + value = state.ladderFrom, + onValueChange = viewModel::onLadderFromChange, + label = { Text(stringResource(R.string.sell_wizard_ladder_from)) }, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = state.ladderTo, + onValueChange = viewModel::onLadderToChange, + label = { Text(stringResource(R.string.sell_wizard_ladder_to)) }, + modifier = Modifier.weight(1f) + ) + } + + OutlinedTextField( + value = state.ladderCount, + onValueChange = viewModel::onLadderCountChange, + label = { Text(stringResource(R.string.sell_wizard_ladder_count)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + + SegmentedButton( + options = listOf( + LadderGenerator.AmountMode.EQUAL_CRYPTO to stringResource(R.string.sell_wizard_ladder_amount_equal_crypto), + LadderGenerator.AmountMode.EQUAL_FIAT to stringResource(R.string.sell_wizard_ladder_amount_equal_fiat) + ), + selected = state.ladderAmountMode, + onSelect = viewModel::onLadderAmountModeChange + ) + + LadderPreviewTable( + orders = state.ladderPreview, + avg = state.avgBuyPrice.toBigDecimalOrNull(), + feeRate = state.feeRate + ) +} +``` + +(Pokud `SegmentedButton` nemate, pouzit FilterChip Row alternativu.) + +- [ ] **Krok 3: Composable LadderPreviewTable** + +```kotlin +@Composable +fun LadderPreviewTable(orders: List, avg: BigDecimal?, feeRate: BigDecimal) { + if (orders.isEmpty()) return + Column(modifier = Modifier.fillMaxWidth()) { + // Header + Row(modifier = Modifier.fillMaxWidth()) { + Text("#", modifier = Modifier.weight(0.5f)) + Text(stringResource(R.string.sell_wizard_summary_amount), modifier = Modifier.weight(1.5f)) + Text(stringResource(R.string.sell_wizard_limit_price), modifier = Modifier.weight(2f)) + Text("Profit %", modifier = Modifier.weight(1.5f)) + Text(stringResource(R.string.sell_wizard_summary_net_profit), modifier = Modifier.weight(2f)) + } + Divider() + + var totalNet = BigDecimal.ZERO + orders.forEachIndexed { i, o -> + val gross = o.cryptoAmount * o.limitPrice + val net = gross * (BigDecimal.ONE - feeRate) + val profitPct = if (avg != null && avg > BigDecimal.ZERO) + (o.limitPrice - avg).divide(avg, 4, RoundingMode.HALF_UP).movePointRight(2) + else null + totalNet = totalNet + (net - o.cryptoAmount * (avg ?: BigDecimal.ZERO)) + + Row(modifier = Modifier.fillMaxWidth()) { + Text("${i + 1}", modifier = Modifier.weight(0.5f)) + Text(o.cryptoAmount.setScale(8, RoundingMode.DOWN).stripTrailingZeros().toPlainString(), + modifier = Modifier.weight(1.5f)) + Text(o.limitPrice.setScale(2, RoundingMode.HALF_UP).toPlainString(), + modifier = Modifier.weight(2f)) + Text(profitPct?.toPlainString()?.let { "$it%" } ?: "-", + modifier = Modifier.weight(1.5f)) + Text(net.setScale(2, RoundingMode.HALF_UP).toPlainString(), + modifier = Modifier.weight(2f)) + } + } + Divider() + Text( + "${stringResource(R.string.sell_wizard_ladder_preview_total)}: " + + totalNet.setScale(2, RoundingMode.HALF_UP).toPlainString(), + style = MaterialTheme.typography.titleSmall + ) + } +} +``` + +- [ ] **Krok 4: V ladder modu skryt singl-mode pole `Cisty vynos` a presety** + +V krocich kde se rendruje single mode UI: obalit do `if (!state.ladderEnabled) { ... }`. + +Limit price pole zustava (single nazev), v ladder modu se schova a zobrazi se `Od/Do`. + +- [ ] **Krok 5: Build a commit** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +git add ... +git commit -m "feat(sell): ladder UI with range, count, amount-mode toggle, preview table" +``` + +--- + +### Task 19: Ladder submit flow + post-submit dialog + +**Files:** +- Modify: `SellWizardViewModel.kt` +- Modify: `SellWizardBottomSheet.kt` +- Modify: `strings.xml` + +- [ ] **Krok 1: Stringy** + +```xml + +Placed %1$d of %2$d orders. Remaining did not proceed: %3$s +All %1$d orders placed + + +Vytvoreno %1$d z %2$d orderu. Zbyvajici nepokracovaly: %3$s +Vsech %1$d orderu vytvoreno +``` + +- [ ] **Krok 2: ViewModel - submit ladder** + +```kotlin +@Inject lateinit var placeLadderSellUseCase: PlaceLadderSellUseCase + +suspend fun submitLadder(): LadderResult? { + val st = _state.value + if (!st.ladderEnabled || st.ladderPreview.isEmpty()) return null + return placeLadderSellUseCase(st.planId, st.ladderPreview) +} +``` + +- [ ] **Krok 3: Krok 2 wizardu - rozliseni single vs ladder pri submitu** + +V kroku 2 (potvrzeni): + +```kotlin +val coroutineScope = rememberCoroutineScope() +Button(onClick = { + coroutineScope.launch { + if (state.ladderEnabled) { + val result = viewModel.submitLadder() + // ukazat dialog dle vysledku + when (result) { + is LadderResult.AllPlaced -> showDialog(R.string.sell_wizard_ladder_all_placed, result.placedTxIds.size) + is LadderResult.PartialFailure -> showDialog( + R.string.sell_wizard_ladder_partial_success, + result.placedTxIds.size, result.totalCount, result.reason + ) + null -> {} + } + onDismiss() + } else { + viewModel.submitSingle() + onDismiss() + } + } +}) { + Text(stringResource(R.string.sell_wizard_submit)) +} +``` + +- [ ] **Krok 4: Build a commit** + +```bash +cd accbot-android && ./gradlew :app:compileDebugKotlin +git add ... +git commit -m "feat(sell): ladder submit flow + partial-success dialog" +``` + +--- + +### Task 20: Manualni overeni ladder modu + +Bez code zmen. + +- [ ] **Krok 1: Build & install debug APK** + +```bash +cd accbot-android && ./gradlew :app:assembleDebug && \ +adb install -r accbot-android/app/build/outputs/apk/debug/app-debug.apk +``` + +- [ ] **Krok 2: Scenare** + +- **Aktivace ladder**: zaskrtnout, single pole se schova, ladder pole se ukaze, preview prazdne. +- **Zadat range cena**: from=2000000, to=2400000, count=5, total amount=1 BTC. Preview ukaze 5 orderu po 0.2 BTC s rovnomerne rozprostrenymi cenami. +- **Toggle profit %**: prepnout, pole `Od/Do` jsou ted procenta nad avg. Zadat 10/30, preview vyrenderuje ceny avg×1.10 az avg×1.30. +- **Toggle equal-fiat**: preview se prerovna - levnejsi ordery vetsi mnozstvi. +- **Submit**: kliknout Pokracovat, Krok 2 zobrazi preview + agregat. Submit -> burza dostane N orderu. +- **Stop & report**: jak otestovat? Coinmate sandbox neexistuje, fakov failure jde: + - Zadat ladder s prilis malymi mnozstvimi (under minOrderSize) - validace by mela zachytit pred submitem. + - Pokud chces realny stop&report test: nastavit `to` velmi vysoko (mimo limity burzy), 1-2 ordery proveddu, zbytek fail. +- **Plan delete**: po vytvoreni ladderu zkusit smazat plan. Mel by byt blokovany (existujici Task 31 logiky). + +- [ ] **Krok 3: Pripadne opravy** + +Maly commit `fix(sell): ...` per opravu. + +--- + +## Faze 7: E2E zacleneni + +### Task 21: Aktualizace E2E checklistu pro Task 33 / Task 34 z puvodniho planu + +**Files:** +- Modify: `docs/superpowers/plans/2026-04-23-dca-sell-extension.md` + +- [ ] **Krok 1: V Task 33 (Coinmate manualni sandbox test) doplnit nove scenare:** + +```markdown +**Scenar E - cost basis prefill:** +- Otevrit sell wizard na planu s 3 buys ruznych cen +- Overit ze avg buy price prefilled, hodnota matematicky odpovida cheapest-first vypoctu +- Manualni override -> zmeni se hodnota, "✏️ Zadano rucne" indikator +- Reset -> vrati auto + +**Scenar F - 3-pole kalkulacka:** +- Zadat A a P, N se dopocita +- Zadat A a N, P se dopocita +- Cenovy preset +20% z avg -> P = avg × 1.20 +- Net preset +20% -> N = A × avg × 1.20 + +**Scenar G - loss warning:** +- Zadat P pod avg buy -> banner "Prodavas pod nakupni cenou", cervene profit +- Zadat P tesne nad avg (~0.3%) -> banner "Po fee se ztratou" + +**Scenar H - ladder mode:** +- Zaskrtnout checkbox, zadat from/to/count, total amount +- Preview tabulka renderuje N orderu +- Toggle equal-fiat -> uneven crypto amounts +- Submit -> N PENDING SELL transakci na burze, vsechny v plan-detail open orders + +**Scenar I - ladder partial failure:** +- Zadat ladder mimo limity Coinmate (extremne vysoky `to`) +- Submit -> dialog "Vytvoreno X z N orderu, zbyvajici: " +- Plan-detail ukazuje X PENDING orderu +``` + +- [ ] **Krok 2: V Task 34 (Binance) totez (analogicke scenare E-I)** + +- [ ] **Krok 3: Commit** + +```bash +git add docs/superpowers/plans/2026-04-23-dca-sell-extension.md +git commit -m "docs(sell): extend Task 33/34 E2E with cost-basis + ladder scenarios" +``` + +--- + +## Summary + +**Celkem tasku:** 21 +**Predpokladany rozsah:** 2-3 dny pro experienced Kotlin/Compose dev. Vetsina komplexity je v UI state machine a v korektnim provazani s existujicimi flowy. + +**Kriticke zavislosti v poradi:** +- Task 1-3 (cost basis foundation) MUSI byt hotove pred 7+ (ViewModel) +- Task 4 (fee plumbing) potreba pred 5 (loss check) a 7 (calculator) +- Task 14-16 (ladder use case + generator + validation) pred 17-19 (UI) +- Tasky 13 a 20 (manualni testy) konci jednotlivych mod +- Task 21 (E2E zacleneni) jen po vsem + +**TDD pokryti:** +- `CalculatePlanCostBasisUseCase.computeCostBasis` - 9 testu +- `ValidateSellOrderUseCase.checkLoss` - 4 testy +- `SellCalculatorMath.recompute` - 5 testu +- `LadderGenerator.generate` - 3 testy +- UI a ViewModel state machine - manualni testy v Task 13 / Task 20 + +**Co se NEmeni:** +- DB schema, migrace +- Backup/restore +- `CalculatePlanPnLUseCase` +- `SellPollingWorker`, `ResolvePendingTransactionsUseCase` +- `PlaceLimitSellUseCase` (ladder = vlastni cesta) + +**Out of scope (viz spec):** cache, snapshot avg na sell, hard block na loss, geometric distribuce, atomic batch, persistovane preset preferences. From 4d1d536a19ec16aafc604408cc28cec9e8046b24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 12:18:07 +0200 Subject: [PATCH 39/75] fix(coinmate): validate credentials without probing BTC balance /balances returns valid data even when account has no BTC entry, but the old check probed for BTC specifically and silently returned null when absent, surfacing as 'Invalid API credentials' to the user. Switch to checking error=false + data is JSONObject so any populated account passes regardless of which currencies are held. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/accbot/dca/exchange/CoinmateApi.kt | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt index 98ad4d9..aacd3c0 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt @@ -571,7 +571,28 @@ class CoinmateApi( override suspend fun validateCredentials(): Boolean = withContext(Dispatchers.IO) { try { - getBalance("BTC") != null + val nonce = System.currentTimeMillis() + val signature = createSignature(nonce) + + val formBody = FormBody.Builder() + .add("clientId", clientId) + .add("publicKey", credentials.apiKey) + .add("nonce", nonce.toString()) + .add("signature", signature) + .build() + + val request = Request.Builder() + .url("$baseUrl/balances") + .post(formBody) + .build() + + client.newCall(request).execute().use { response -> + val body = response.body?.string() ?: return@use false + val json = JSONObject(body) + // Valid credentials = server accepted the signed request and returned a data object. + // Don't probe a specific currency - new accounts may not have it yet. + !json.optBoolean("error", true) && json.opt("data") is JSONObject + } } catch (e: Exception) { false } From abeb07a50a493152077857b8b4822cac54519d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 12:23:41 +0200 Subject: [PATCH 40/75] feat(sell): add RemainingInventory model for cost basis algorithm Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dca/domain/model/RemainingInventory.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/domain/model/RemainingInventory.kt diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/RemainingInventory.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/RemainingInventory.kt new file mode 100644 index 0000000..7135852 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/RemainingInventory.kt @@ -0,0 +1,28 @@ +package com.accbot.dca.domain.model + +import java.math.BigDecimal + +/** + * Result of [com.accbot.dca.domain.usecase.CalculatePlanCostBasisUseCase] - timestamp-aware + * cheapest-first remaining inventory of buys after applying past sells (including PENDING + * reservations). + */ +data class RemainingInventory( + /** Sum of remaining crypto across all buys with non-consumed portion. */ + val available: BigDecimal, + + /** Volume-weighted avg buy price of [perBuyDetail]. Null when [available] == 0. */ + val weightedAvgPrice: BigDecimal?, + + /** Per-buy remaining state (only buys with > 0 remaining). For debug / future features. */ + val perBuyDetail: List, + + /** > 0 when historical sells exceed buys (data inconsistency, e.g. CSV import edge case). */ + val deficit: BigDecimal +) + +data class RemainingBuy( + val transactionId: Long, + val price: BigDecimal, + val remaining: BigDecimal +) From 1bac89390b659f32573d5072dd50caa65efba99b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 12:31:34 +0200 Subject: [PATCH 41/75] feat(sell): CalculatePlanCostBasisUseCase with timestamp-aware cheapest-first Pure function in companion object enables 9 unit tests covering empty plans, simple buys, cheapest-first consumption, timestamp filtering, PENDING/PARTIAL reservations, FAILED ignore, deficit handling, and tie breaking. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../usecase/CalculatePlanCostBasisUseCase.kt | 105 ++++++++++ .../CalculatePlanCostBasisUseCaseTest.kt | 180 ++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCase.kt create mode 100644 accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCaseTest.kt diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCase.kt new file mode 100644 index 0000000..eedbd1d --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCase.kt @@ -0,0 +1,105 @@ +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.TransactionEntity +import com.accbot.dca.domain.model.RemainingBuy +import com.accbot.dca.domain.model.RemainingInventory +import com.accbot.dca.domain.model.TransactionSide +import com.accbot.dca.domain.model.TransactionStatus +import java.math.BigDecimal +import java.math.RoundingMode +import javax.inject.Inject + +/** + * Compute remaining inventory and weighted-average buy price for a plan using the + * timestamp-aware cheapest-first algorithm. + * + * Each historical sell (chronologically ordered) consumes from buys that preceded it, + * cheapest first; pending/partial sells reserve the unfilled portion. The result is + * stable against new cheap buys after a sell, because such buys are not in scope for + * past sells (timestamp filter). + * + * Pure logic in [computeCostBasis] for unit testing without DB / Hilt. + */ +class CalculatePlanCostBasisUseCase @Inject constructor( + private val database: DcaDatabase +) { + suspend operator fun invoke(planId: Long): RemainingInventory { + val transactions = database.transactionDao().getTransactionsByPlanSync(planId) + return computeCostBasis(transactions) + } + + companion object { + fun computeCostBasis(transactions: List): RemainingInventory { + val buys = transactions.filter { + it.side == TransactionSide.BUY && + (it.status == TransactionStatus.COMPLETED || it.status == TransactionStatus.PARTIAL) + } + + val sells = transactions.filter { + it.side == TransactionSide.SELL && + (it.status == TransactionStatus.COMPLETED || + it.status == TransactionStatus.PARTIAL || + it.status == TransactionStatus.PENDING) + }.sortedBy { it.executedAt } + + val consumed = HashMap(buys.size) + for (b in buys) consumed[b.id] = BigDecimal.ZERO + + var totalDeficit = BigDecimal.ZERO + + for (sell in sells) { + val toConsume = effectiveConsumption(sell) + if (toConsume <= BigDecimal.ZERO) continue + var remaining = toConsume + + val eligible = buys + .filter { it.executedAt.isBefore(sell.executedAt) } + .filter { + (it.cryptoAmount - (consumed[it.id] ?: BigDecimal.ZERO)) > BigDecimal.ZERO + } + .sortedWith(compareBy({ it.price }, { it.executedAt })) + + for (b in eligible) { + if (remaining <= BigDecimal.ZERO) break + val available = b.cryptoAmount - (consumed[b.id] ?: BigDecimal.ZERO) + val take = remaining.min(available) + consumed[b.id] = (consumed[b.id] ?: BigDecimal.ZERO) + take + remaining -= take + } + + if (remaining > BigDecimal.ZERO) totalDeficit = totalDeficit + remaining + } + + val perBuyDetail = buys.mapNotNull { b -> + val left = b.cryptoAmount - (consumed[b.id] ?: BigDecimal.ZERO) + if (left > BigDecimal.ZERO) RemainingBuy(b.id, b.price, left) else null + } + + val available = perBuyDetail.fold(BigDecimal.ZERO) { acc, rb -> acc + rb.remaining } + val weightedAvg = if (available > BigDecimal.ZERO) { + val sumCost = perBuyDetail.fold(BigDecimal.ZERO) { acc, rb -> + acc + rb.remaining * rb.price + } + sumCost.divide(available, 8, RoundingMode.HALF_UP) + } else null + + return RemainingInventory( + available = available, + weightedAvgPrice = weightedAvg, + perBuyDetail = perBuyDetail, + deficit = totalDeficit + ) + } + + /** + * Crypto reserved/consumed by a sell. PENDING/PARTIAL: full requested amount (filled + * + still-reserved unfilled portion). COMPLETED: cryptoAmount (= requested when fully + * filled). max() guards against rare overflow if filled > requested. + */ + private fun effectiveConsumption(sell: TransactionEntity): BigDecimal { + val requested = sell.requestedCryptoAmount ?: BigDecimal.ZERO + return requested.max(sell.cryptoAmount) + } + } +} diff --git a/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCaseTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCaseTest.kt new file mode 100644 index 0000000..b59a95e --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/CalculatePlanCostBasisUseCaseTest.kt @@ -0,0 +1,180 @@ +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.TransactionEntity +import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.TransactionSide +import com.accbot.dca.domain.model.TransactionStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.math.BigDecimal +import java.time.Instant + +class CalculatePlanCostBasisUseCaseTest { + + private val t0: Instant = Instant.parse("2026-01-01T00:00:00Z") + private fun ts(daysOffset: Long): Instant = t0.plusSeconds(daysOffset * 86_400) + + private fun buy( + id: Long, + crypto: BigDecimal, + price: BigDecimal, + executedAt: Instant, + status: TransactionStatus = TransactionStatus.COMPLETED + ): TransactionEntity = TransactionEntity( + id = id, + planId = 1, + connectionId = 1, + exchange = Exchange.COINMATE, + crypto = "BTC", + fiat = "CZK", + fiatAmount = crypto * price, + cryptoAmount = crypto, + price = price, + fee = BigDecimal.ZERO, + feeAsset = "CZK", + status = status, + exchangeOrderId = "buy-$id", + executedAt = executedAt, + side = TransactionSide.BUY + ) + + private fun sell( + id: Long, + crypto: BigDecimal, + price: BigDecimal, + executedAt: Instant, + status: TransactionStatus = TransactionStatus.COMPLETED, + requested: BigDecimal? = null + ): TransactionEntity = TransactionEntity( + id = id, + planId = 1, + connectionId = 1, + exchange = Exchange.COINMATE, + crypto = "BTC", + fiat = "CZK", + fiatAmount = crypto * price, + cryptoAmount = crypto, + price = price, + fee = BigDecimal.ZERO, + feeAsset = "CZK", + status = status, + exchangeOrderId = "sell-$id", + executedAt = executedAt, + side = TransactionSide.SELL, + requestedCryptoAmount = requested ?: crypto, + limitPrice = price + ) + + @Test + fun `prazdny plan vraci available nula a avg null`() { + val result = CalculatePlanCostBasisUseCase.computeCostBasis(emptyList()) + assertEquals(0, BigDecimal.ZERO.compareTo(result.available)) + assertNull(result.weightedAvgPrice) + assertTrue(result.perBuyDetail.isEmpty()) + assertEquals(0, BigDecimal.ZERO.compareTo(result.deficit)) + } + + @Test + fun `jeden buy bez sells - available a avg jsou z buyu`() { + val txs = listOf(buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0))) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal("1").compareTo(result.available)) + assertEquals(0, BigDecimal("1000000").compareTo(result.weightedAvgPrice!!)) + } + + @Test + fun `dva buys, sell konzumuje cheapest first`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + buy(2, BigDecimal("1"), BigDecimal("2000000"), ts(1)), + sell(3, BigDecimal("0.5"), BigDecimal("2500000"), ts(2)) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + // 0.5 BTC zkonzumovano z 1M buyu, zbyva 0.5 BTC @ 1M + 1 BTC @ 2M + assertEquals(0, BigDecimal("1.5").compareTo(result.available)) + // weighted avg = (0.5 * 1M + 1 * 2M) / 1.5 = ~1666666.67 + val expected = BigDecimal("1666666.66666667") + assertEquals(0, expected.compareTo(result.weightedAvgPrice!!)) + } + + @Test + fun `novy levny buy po sellu neovlivni avg pro driv prodane`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + sell(2, BigDecimal("0.5"), BigDecimal("2000000"), ts(1)), + buy(3, BigDecimal("0.5"), BigDecimal("800000"), ts(2)) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + // Sell @ts(1) sees only buy 1. Consumes 0.5 from 1M. + // Remaining: 0.5 @ 1M + 0.5 @ 800k = avg (500k + 400k) / 1.0 = 900k + assertEquals(0, BigDecimal("1.0").compareTo(result.available)) + assertEquals(0, BigDecimal("900000").compareTo(result.weightedAvgPrice!!)) + } + + @Test + fun `PENDING sell rezervuje cheapest`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + sell( + 2, BigDecimal.ZERO, BigDecimal("3000000"), ts(1), + status = TransactionStatus.PENDING, requested = BigDecimal("0.5") + ) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal("0.5").compareTo(result.available)) + assertEquals(0, BigDecimal("1000000").compareTo(result.weightedAvgPrice!!)) + } + + @Test + fun `PARTIAL sell pouziva requested ne filled`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + sell( + 2, BigDecimal("0.2"), BigDecimal("3000000"), ts(1), + status = TransactionStatus.PARTIAL, requested = BigDecimal("0.5") + ) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal("0.5").compareTo(result.available)) + } + + @Test + fun `FAILED sell se ignoruje`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + sell( + 2, BigDecimal("0.5"), BigDecimal("3000000"), ts(1), + status = TransactionStatus.FAILED + ) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal("1").compareTo(result.available)) + } + + @Test + fun `negative inventory - sells presahly buys, deficit non-zero`() { + val txs = listOf( + buy(1, BigDecimal("0.5"), BigDecimal("1000000"), ts(0)), + sell(2, BigDecimal("1"), BigDecimal("2000000"), ts(1)) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal.ZERO.compareTo(result.available)) + assertNull(result.weightedAvgPrice) + assertEquals(0, BigDecimal("0.5").compareTo(result.deficit)) + } + + @Test + fun `tie na cene - starsi executedAt napred`() { + val txs = listOf( + buy(1, BigDecimal("1"), BigDecimal("1000000"), ts(0)), + buy(2, BigDecimal("1"), BigDecimal("1000000"), ts(1)), + sell(3, BigDecimal("0.5"), BigDecimal("2000000"), ts(2)) + ) + val result = CalculatePlanCostBasisUseCase.computeCostBasis(txs) + assertEquals(0, BigDecimal("1.5").compareTo(result.available)) + val cheap1 = result.perBuyDetail.firstOrNull { it.transactionId == 1L } + assertEquals(0, BigDecimal("0.5").compareTo(cheap1?.remaining ?: BigDecimal.ZERO)) + } +} From f510a11ec1776c8fd9fcee8c63aa53cbd0c6cf6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 12:37:03 +0200 Subject: [PATCH 42/75] feat(sell): add estimatedTakerFeeRate to ExchangeApi for fee math in wizard Default 0.002 in interface, overridden per exchange: Coinmate 0.0035, Binance 0.001, Coinbase 0.0040, Kraken 0.0026, KuCoin 0.001. Bitfinex/Huobi use the default (their validateCredentials returns false anyway). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/java/com/accbot/dca/exchange/BinanceApi.kt | 2 ++ .../src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt | 2 ++ .../src/main/java/com/accbot/dca/exchange/CoinmateApi.kt | 2 ++ .../src/main/java/com/accbot/dca/exchange/ExchangeApi.kt | 7 +++++++ .../main/java/com/accbot/dca/exchange/OtherExchanges.kt | 4 ++++ 5 files changed, 17 insertions(+) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt index 06e1042..612b919 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt @@ -31,6 +31,8 @@ class BinanceApi( override val supportsLimitSell: Boolean = true + override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.001") + private val baseUrl = ExchangeConfig.getBaseUrl(Exchange.BINANCE, isSandbox) /** Offset in ms: serverTime - localTime. Add to System.currentTimeMillis() to get server time. */ diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt index 3057064..6d2d4eb 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt @@ -31,6 +31,8 @@ class CoinbaseApi( override val exchange = Exchange.COINBASE + override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.0040") + private val baseUrl = ExchangeConfig.getBaseUrl(Exchange.COINBASE, isSandbox) private val jsonMediaType = "application/json".toMediaType() diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt index aacd3c0..f7203ba 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt @@ -31,6 +31,8 @@ class CoinmateApi( // Coinmate taker fee: 0.35% (same as .NET CoinmateAPI.getTakerFee()) private val takerFeeRate = BigDecimal("0.0035") + override val estimatedTakerFeeRate: BigDecimal = takerFeeRate + private val baseUrl = ExchangeConfig.getBaseUrl(Exchange.COINMATE, isSandbox) private val clientId: String = credentials.clientId diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/ExchangeApi.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/ExchangeApi.kt index fedc68f..c6c6a53 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/ExchangeApi.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/ExchangeApi.kt @@ -120,6 +120,13 @@ interface ExchangeApi { */ val supportsLimitSell: Boolean get() = false + /** + * Estimated taker fee rate (e.g. 0.0035 = 0.35%) for decision support in the sell wizard. + * Approximate; actual user fee may be lower with VIP tier or fee discounts (e.g. BNB on + * Binance). Default 0.002 used for exchanges where the actual rate is unknown. + */ + val estimatedTakerFeeRate: BigDecimal get() = BigDecimal("0.002") + /** * Get trade history for a currency pair. * Not all exchanges support this - default throws UnsupportedOperationException. diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt index 4aed1b6..34f005a 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt @@ -24,6 +24,8 @@ class KrakenApi( ) : ExchangeApi { override val exchange = Exchange.KRAKEN + override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.0026") + private val baseUrl = ExchangeConfig.getBaseUrl(Exchange.KRAKEN, isSandbox) private val formMediaType = "application/x-www-form-urlencoded".toMediaType() @@ -435,6 +437,8 @@ class KuCoinApi( ) : ExchangeApi { override val exchange = Exchange.KUCOIN + override val estimatedTakerFeeRate: BigDecimal = BigDecimal("0.001") + private val baseUrl = ExchangeConfig.getBaseUrl(Exchange.KUCOIN, isSandbox) private fun signedRequest(method: String, endpoint: String, body: String? = null): Request.Builder { From 1a8f208a5738c9246633352fc54aa022b0a6ca91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 12:42:28 +0200 Subject: [PATCH 43/75] feat(sell): LossWarning in ValidateSellOrderUseCase Trigger fires on net-of-fee profit < 0, so just-above-avg prices that become losses after fee are still surfaced. Pure helper checkLoss is unit-tested with 4 cases. Default-arg signature keeps existing callers compatible until VM wiring updates them. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../usecase/ValidateSellOrderUseCase.kt | 39 ++++++++++++- .../plans/sell/SellWizardBottomSheet.kt | 1 + .../ValidateSellOrderUseCaseLossTest.kt | 55 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCaseLossTest.kt diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt index 0095679..b6d9bef 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt @@ -16,6 +16,13 @@ sealed class SellValidation { data class HardError(val message: String) : SellValidation() data class InstantFillInfo(val spot: BigDecimal) : SellValidation() data class FarFromMarketWarning(val spot: BigDecimal) : SellValidation() + /** + * Net profit after exchange fee would be negative for this order. Triggered both when + * the limit price is below avg buy and when the price is just above avg buy but fee + * pushes the result into negative territory. Caller (UI) shows a warning banner; + * the wizard still allows the user to proceed - the user makes the final decision. + */ + data class LossWarning(val lossFiat: BigDecimal, val lossPct: Double) : SellValidation() } /** @@ -37,7 +44,9 @@ class ValidateSellOrderUseCase @Inject constructor( cryptoAmount: BigDecimal, limitPrice: BigDecimal, minOrderSize: BigDecimal, - currentSpot: BigDecimal? + currentSpot: BigDecimal?, + avgBuyPrice: BigDecimal? = null, + feeRate: BigDecimal = BigDecimal.ZERO ): List { val result = mutableListOf() @@ -91,7 +100,35 @@ class ValidateSellOrderUseCase @Inject constructor( } } + checkLoss(cryptoAmount, limitPrice, avgBuyPrice, feeRate)?.let { result += it } + if (result.isEmpty()) result += SellValidation.Ok return result } + + companion object { + /** + * Pure helper: returns LossWarning when the net-of-fee profit would be negative. + * Triggers also when [limitPrice] is just above [avgBuyPrice] but fee pushes the + * result negative. Returns null when [avgBuyPrice] is unknown. + */ + internal fun checkLoss( + cryptoAmount: BigDecimal, + limitPrice: BigDecimal, + avgBuyPrice: BigDecimal?, + feeRate: BigDecimal + ): SellValidation.LossWarning? { + if (avgBuyPrice == null || avgBuyPrice <= BigDecimal.ZERO) return null + if (cryptoAmount <= BigDecimal.ZERO || limitPrice <= BigDecimal.ZERO) return null + val grossFiat = cryptoAmount * limitPrice + val netFiat = grossFiat * (BigDecimal.ONE - feeRate) + val costBasis = cryptoAmount * avgBuyPrice + val netProfit = netFiat - costBasis + if (netProfit >= BigDecimal.ZERO) return null + val lossPct = if (costBasis > BigDecimal.ZERO) { + netProfit.toDouble() / costBasis.toDouble() + } else 0.0 + return SellValidation.LossWarning(lossFiat = netProfit.negate(), lossPct = -lossPct) + } + } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index 525bcf4..f0bf84a 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -264,6 +264,7 @@ private fun SellInputStep( is SellValidation.FarFromMarketWarning -> WarningBanner( stringResource(R.string.sell_wizard_far_from_market_warning) ) + is SellValidation.LossWarning -> { /* shown via LossBanner in summary section */ } is SellValidation.Ok -> { /* no-op */ } } } diff --git a/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCaseLossTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCaseLossTest.kt new file mode 100644 index 0000000..c7922e2 --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCaseLossTest.kt @@ -0,0 +1,55 @@ +package com.accbot.dca.domain.usecase + +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import java.math.BigDecimal + +class ValidateSellOrderUseCaseLossTest { + + @Test + fun `pod nakupni cenou vraci LossWarning`() { + val w = ValidateSellOrderUseCase.checkLoss( + cryptoAmount = BigDecimal("1"), + limitPrice = BigDecimal("900000"), + avgBuyPrice = BigDecimal("1000000"), + feeRate = BigDecimal("0.0035") + ) + assertNotNull(w) + } + + @Test + fun `tesne nad nakupni cenou ale po fee ztrata vraci LossWarning`() { + // P=1003500, avg=1M, fee=0.0035 -> netFiat = 1003500 * 0.9965 ~= 999988.75 + // netProfit = 999988.75 - 1000000 = -11.25 < 0 + val w = ValidateSellOrderUseCase.checkLoss( + cryptoAmount = BigDecimal("1"), + limitPrice = BigDecimal("1003500"), + avgBuyPrice = BigDecimal("1000000"), + feeRate = BigDecimal("0.0035") + ) + assertNotNull(w) + } + + @Test + fun `dostatecne nad nakupni cenou vraci null`() { + val w = ValidateSellOrderUseCase.checkLoss( + cryptoAmount = BigDecimal("1"), + limitPrice = BigDecimal("1100000"), + avgBuyPrice = BigDecimal("1000000"), + feeRate = BigDecimal("0.0035") + ) + assertNull(w) + } + + @Test + fun `null avg vraci null`() { + val w = ValidateSellOrderUseCase.checkLoss( + cryptoAmount = BigDecimal("1"), + limitPrice = BigDecimal("900000"), + avgBuyPrice = null, + feeRate = BigDecimal("0.0035") + ) + assertNull(w) + } +} From 252dec8114ec98ce417c9434e7e46fac32e76102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 12:43:21 +0200 Subject: [PATCH 44/75] feat(sell): SellCalculatorMath pure helper for amount/price/net field Co-Authored-By: Claude Opus 4.7 (1M context) --- .../screens/plans/sell/SellCalculatorMath.kt | 60 ++++++++++++++ .../plans/sell/SellCalculatorMathTest.kt | 79 +++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMath.kt create mode 100644 accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMathTest.kt diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMath.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMath.kt new file mode 100644 index 0000000..21b0132 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMath.kt @@ -0,0 +1,60 @@ +package com.accbot.dca.presentation.screens.plans.sell + +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Pure logic for the three-field sell calculator (Amount / Price / Net). + * + * Relationship: `N = A * P * (1 - feeRate)` + * + * The wizard ViewModel records the two most-recently edited fields. When the user types in + * the third field it becomes the "newest"; the field that drops out of the recent-edits pair + * is the one we recompute. [recompute] does this in one shot. + */ +object SellCalculatorMath { + + enum class Field { AMOUNT, PRICE, NET } + + /** + * @param a current amount value (parsed from input, null when blank) + * @param p current price value + * @param n current net value + * @param feeRate exchange fee, e.g. 0.0035 for Coinmate taker + * @param lastTwoEdited fields most recently edited in newest-first order + * @return updated triple with the third field recomputed when possible + */ + fun recompute( + a: BigDecimal?, + p: BigDecimal?, + n: BigDecimal?, + feeRate: BigDecimal, + lastTwoEdited: List + ): Triple { + if (lastTwoEdited.size < 2) return Triple(a, p, n) + val factor = BigDecimal.ONE - feeRate + val toCompute = Field.values().firstOrNull { it !in lastTwoEdited } + ?: return Triple(a, p, n) + + return when (toCompute) { + Field.NET -> { + val newN = if (a != null && p != null && a > BigDecimal.ZERO && p > BigDecimal.ZERO) + (a * p * factor).setScale(2, RoundingMode.HALF_UP) + else null + Triple(a, p, newN) + } + Field.PRICE -> { + val newP = if (a != null && n != null && a > BigDecimal.ZERO && factor > BigDecimal.ZERO) + n.divide(a * factor, 2, RoundingMode.HALF_UP) + else null + Triple(a, newP, n) + } + Field.AMOUNT -> { + val newA = if (p != null && n != null && p > BigDecimal.ZERO && factor > BigDecimal.ZERO) + n.divide(p * factor, 8, RoundingMode.HALF_UP) + else null + Triple(newA, p, n) + } + } + } +} diff --git a/accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMathTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMathTest.kt new file mode 100644 index 0000000..d3b5c8f --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/SellCalculatorMathTest.kt @@ -0,0 +1,79 @@ +package com.accbot.dca.presentation.screens.plans.sell + +import com.accbot.dca.presentation.screens.plans.sell.SellCalculatorMath.Field +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.math.BigDecimal + +class SellCalculatorMathTest { + + private val fee = BigDecimal("0.0035") + + @Test + fun `A a P editovane - dopocita N`() { + val (a, p, n) = SellCalculatorMath.recompute( + a = BigDecimal("1"), + p = BigDecimal("1000000"), + n = null, + feeRate = fee, + lastTwoEdited = listOf(Field.PRICE, Field.AMOUNT) + ) + // 1 * 1000000 * 0.9965 = 996500 + assertEquals(0, BigDecimal("996500.00").compareTo(n!!)) + assertEquals(0, BigDecimal("1").compareTo(a!!)) + assertEquals(0, BigDecimal("1000000").compareTo(p!!)) + } + + @Test + fun `A a N editovane - dopocita P`() { + val (_, p, _) = SellCalculatorMath.recompute( + a = BigDecimal("1"), + p = null, + n = BigDecimal("996500"), + feeRate = fee, + lastTwoEdited = listOf(Field.NET, Field.AMOUNT) + ) + // 996500 / (1 * 0.9965) = 1000000 + assertEquals(0, BigDecimal("1000000.00").compareTo(p!!)) + } + + @Test + fun `P a N editovane - dopocita A`() { + val (a, _, _) = SellCalculatorMath.recompute( + a = null, + p = BigDecimal("1000000"), + n = BigDecimal("996500"), + feeRate = fee, + lastTwoEdited = listOf(Field.NET, Field.PRICE) + ) + // 996500 / (1000000 * 0.9965) = 1.0 + assertEquals(0, BigDecimal("1.00000000").compareTo(a!!)) + } + + @Test + fun `mene nez 2 editovana pole - nedopocitava`() { + val (_, p, n) = SellCalculatorMath.recompute( + a = BigDecimal("1"), + p = null, + n = null, + feeRate = fee, + lastTwoEdited = listOf(Field.AMOUNT) + ) + assertNull(p) + assertNull(n) + } + + @Test + fun `chybejici vstupy v computed dvojici - vrati null`() { + val (_, _, n) = SellCalculatorMath.recompute( + a = null, + p = BigDecimal("1000000"), + n = null, + feeRate = fee, + lastTwoEdited = listOf(Field.PRICE, Field.AMOUNT) + ) + // computed = NET, ale a je null -> n = null + assertNull(n) + } +} From 1a249033e36a39a3597be5467af55d9a5e8eab97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 12:45:30 +0200 Subject: [PATCH 45/75] feat(sell): wire cost basis + 3-field calculator into SellWizardViewModel avgBuyPrice now sourced from CalculatePlanCostBasisUseCase (cheapest-first with timestamp filter) instead of lifetime PnL. avgBuyPriceInput is editable; avgBuyPriceManual flag tracks deviation from auto so the UI can show a reset chip. setNetFiat completes the 3-field calculator; setAmount/setPrice/setNetFiat all funnel through updateCalculatorField which records the most-recently-edited pair and runs SellCalculatorMath. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../screens/plans/sell/SellWizardViewModel.kt | 160 +++++++++++++----- 1 file changed, 121 insertions(+), 39 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt index 8143c1f..56f872a 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt @@ -7,8 +7,10 @@ import com.accbot.dca.data.local.CredentialsStore import com.accbot.dca.data.local.DcaDatabase import com.accbot.dca.data.local.UserPreferences import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.RemainingInventory import com.accbot.dca.domain.model.TransactionSide import com.accbot.dca.domain.model.TransactionStatus +import com.accbot.dca.domain.usecase.CalculatePlanCostBasisUseCase import com.accbot.dca.domain.usecase.CalculatePlanPnLUseCase import com.accbot.dca.domain.usecase.PlaceLimitSellUseCase import com.accbot.dca.domain.usecase.SellValidation @@ -28,17 +30,20 @@ import javax.inject.Inject /** * State + actions for the two-step limit-sell wizard (input -> confirm -> submit). * - * Lifecycle: the hosting bottom-sheet calls [init] once per open, then [setAmount] / - * [setPrice] / [proceedToConfirm] / [submit] based on user actions. On successful submit - * [UiState.dismissRequested] flips true; the sheet consumes via [consumeDismiss] and - * closes. On timeout [UiState.showTimeoutDialog] is set so the user is warned to check - * the exchange manually for duplicate orders. + * Cost basis: the avg buy price prefilled in the UI is computed via timestamp-aware + * cheapest-first ([CalculatePlanCostBasisUseCase]). Manual override is supported via + * [setAvgBuyPrice]; [resetAvgBuyPrice] returns to auto. + * + * Three-field calculator: [setAmount], [setPrice], [setNetFiat] each record the field as + * "most recently edited"; [SellCalculatorMath.recompute] fills the third field when the + * other two are set. */ @HiltViewModel class SellWizardViewModel @Inject constructor( private val validateSellOrderUseCase: ValidateSellOrderUseCase, private val placeLimitSellUseCase: PlaceLimitSellUseCase, private val calculatePlanPnLUseCase: CalculatePlanPnLUseCase, + private val calculatePlanCostBasisUseCase: CalculatePlanCostBasisUseCase, private val database: DcaDatabase, private val credentialsStore: CredentialsStore, private val exchangeApiFactory: ExchangeApiFactory, @@ -54,13 +59,23 @@ class SellWizardViewModel @Inject constructor( val crypto: String = "", val fiat: String = "", val held: BigDecimal = BigDecimal.ZERO, - /** held crypto minus crypto already reserved by other open sells (unfilled). */ val availableToSell: BigDecimal = BigDecimal.ZERO, val spotPrice: BigDecimal? = null, - val avgBuyPrice: BigDecimal? = null, + /** Auto-computed avg buy price from cheapest-first; null when no buys remain. */ + val avgBuyPriceAuto: BigDecimal? = null, + /** User-entered text for avg; empty string means "use auto". */ + val avgBuyPriceInput: String = "", + /** True when [avgBuyPriceInput] differs from auto - drives the reset chip. */ + val avgBuyPriceManual: Boolean = false, val minOrderSize: BigDecimal = BigDecimal("0.00001"), val amountInput: String = "", val priceInput: String = "", + val netInput: String = "", + val lastTwoEdited: List = emptyList(), + val feeRate: BigDecimal = BigDecimal.ZERO, + val targetProfitAmount: BigDecimal? = null, + val realizedPnLSoFar: BigDecimal = BigDecimal.ZERO, + val inventoryDeficit: BigDecimal = BigDecimal.ZERO, val validations: List = emptyList(), val step: Step = Step.INPUT, val initializing: Boolean = true, @@ -69,7 +84,10 @@ class SellWizardViewModel @Inject constructor( val showTimeoutDialog: Boolean = false, val dismissRequested: Boolean = false ) { - /** Proceed button enabled when no hard errors + both numeric inputs parse. */ + /** Effective avg buy: parsed manual input takes precedence, else auto. */ + val avgBuyPrice: BigDecimal? + get() = if (avgBuyPriceManual) avgBuyPriceInput.toBigDecimalOrNull() else avgBuyPriceAuto + val canProceed: Boolean get() = validations.none { it is SellValidation.HardError } && amountInput.isNotBlank() && priceInput.isNotBlank() && @@ -82,11 +100,6 @@ class SellWizardViewModel @Inject constructor( private var initialized = false - /** - * Load plan state, compute held crypto, available-to-sell (minus reservations from - * open sells), avg buy price and a best-effort spot price. Idempotent across - * recompositions thanks to [initialized]. - */ fun init(planId: Long) { if (initialized) return initialized = true @@ -97,7 +110,6 @@ class SellWizardViewModel @Inject constructor( return@launch } - // Best-effort spot price via exchange API; null means UI shows "-". val credentials = try { credentialsStore.getCredentials(plan.connectionId, userPreferences.isSandboxMode()) } catch (e: Exception) { @@ -113,6 +125,14 @@ class SellWizardViewModel @Inject constructor( null } } + val feeRate = api?.estimatedTakerFeeRate ?: BigDecimal.ZERO + + val inventory: RemainingInventory = try { + calculatePlanCostBasisUseCase(planId) + } catch (e: Exception) { + Log.w(TAG, "cost basis calc failed: ${e.message}") + RemainingInventory(BigDecimal.ZERO, null, emptyList(), BigDecimal.ZERO) + } val pnl = try { calculatePlanPnLUseCase(planId, spot) @@ -120,20 +140,8 @@ class SellWizardViewModel @Inject constructor( Log.w(TAG, "PnL calc failed: ${e.message}") null } - val held = pnl?.currentCryptoHeld ?: BigDecimal.ZERO - val avgBuy = pnl?.avgBuyPrice - - // Crypto reserved by other open sells (requested - filled) - prevents the - // user from submitting a sell that, together with existing open sells, - // exceeds what they actually hold. - val openSells = database.transactionDao().getTransactionsByPlanSync(planId) - .filter { - it.side == TransactionSide.SELL && - it.status in setOf(TransactionStatus.PENDING, TransactionStatus.PARTIAL) - } - val reserved = openSells.fold(BigDecimal.ZERO) { acc, tx -> - acc + ((tx.requestedCryptoAmount ?: BigDecimal.ZERO) - tx.cryptoAmount) - } + val held = pnl?.currentCryptoHeld ?: inventory.available + val realizedSoFar = pnl?.realizedPnL ?: BigDecimal.ZERO val minOrder = when (plan.exchange) { Exchange.BINANCE -> @@ -149,10 +157,16 @@ class SellWizardViewModel @Inject constructor( crypto = plan.crypto, fiat = plan.fiat, held = held, - availableToSell = (held - reserved).max(BigDecimal.ZERO), + availableToSell = inventory.available, spotPrice = spot, - avgBuyPrice = avgBuy, + avgBuyPriceAuto = inventory.weightedAvgPrice, + avgBuyPriceInput = inventory.weightedAvgPrice?.setScale(2, RoundingMode.HALF_UP)?.toPlainString() ?: "", + avgBuyPriceManual = false, minOrderSize = minOrder, + feeRate = feeRate, + targetProfitAmount = plan.targetProfitAmount, + realizedPnLSoFar = realizedSoFar, + inventoryDeficit = inventory.deficit, initializing = false ) } @@ -161,8 +175,7 @@ class SellWizardViewModel @Inject constructor( } fun setAmount(value: String) { - _uiState.update { it.copy(amountInput = value) } - revalidate() + updateCalculatorField(SellCalculatorMath.Field.AMOUNT, value) } fun setAmountPct(pct: Int) { @@ -173,8 +186,11 @@ class SellWizardViewModel @Inject constructor( } fun setPrice(value: String) { - _uiState.update { it.copy(priceInput = value) } - revalidate() + updateCalculatorField(SellCalculatorMath.Field.PRICE, value) + } + + fun setNetFiat(value: String) { + updateCalculatorField(SellCalculatorMath.Field.NET, value) } fun setPriceSpot() { @@ -197,6 +213,75 @@ class SellWizardViewModel @Inject constructor( } } + fun setPriceSpotPlus(pct: Int) { + _uiState.value.spotPrice?.let { spot -> + val multiplier = BigDecimal.ONE + + BigDecimal(pct).divide(BigDecimal(100), 4, RoundingMode.HALF_UP) + setPrice((spot * multiplier).setScale(2, RoundingMode.HALF_UP).toPlainString()) + } + } + + /** Apply a profit-target preset to the net field: N = A * avg * (1 + profitPct). */ + fun applyNetProfitPreset(profitPct: Double) { + val st = _uiState.value + val a = st.amountInput.toBigDecimalOrNull() ?: return + val avg = st.avgBuyPrice ?: return + if (a <= BigDecimal.ZERO || avg <= BigDecimal.ZERO) return + val target = a * avg * (BigDecimal.ONE + BigDecimal(profitPct)) + setNetFiat(target.setScale(2, RoundingMode.HALF_UP).toPlainString()) + } + + fun setAvgBuyPrice(value: String) { + _uiState.update { st -> + val parsed = value.toBigDecimalOrNull() + val isManual = parsed != null && parsed.compareTo(st.avgBuyPriceAuto ?: BigDecimal("-1")) != 0 + st.copy(avgBuyPriceInput = value, avgBuyPriceManual = isManual) + } + revalidate() + } + + fun resetAvgBuyPrice() { + _uiState.update { st -> + st.copy( + avgBuyPriceInput = st.avgBuyPriceAuto?.setScale(2, RoundingMode.HALF_UP)?.toPlainString() ?: "", + avgBuyPriceManual = false + ) + } + revalidate() + } + + private fun updateCalculatorField(field: SellCalculatorMath.Field, text: String) { + _uiState.update { st -> + val newLastTwo = (listOf(field) + st.lastTwoEdited.filter { it != field }).take(2) + val current = mapOf( + SellCalculatorMath.Field.AMOUNT to st.amountInput, + SellCalculatorMath.Field.PRICE to st.priceInput, + SellCalculatorMath.Field.NET to st.netInput + ).toMutableMap() + current[field] = text + val a = current[SellCalculatorMath.Field.AMOUNT]?.toBigDecimalOrNull() + val p = current[SellCalculatorMath.Field.PRICE]?.toBigDecimalOrNull() + val n = current[SellCalculatorMath.Field.NET]?.toBigDecimalOrNull() + val (newA, newP, newN) = SellCalculatorMath.recompute(a, p, n, st.feeRate, newLastTwo) + + // Only overwrite the field that wasn't directly edited; the user-typed text stays as-is + val nextAmount = if (field == SellCalculatorMath.Field.AMOUNT) text + else newA?.stripTrailingZeros()?.toPlainString() ?: st.amountInput + val nextPrice = if (field == SellCalculatorMath.Field.PRICE) text + else newP?.toPlainString() ?: st.priceInput + val nextNet = if (field == SellCalculatorMath.Field.NET) text + else newN?.toPlainString() ?: st.netInput + + st.copy( + amountInput = nextAmount, + priceInput = nextPrice, + netInput = nextNet, + lastTwoEdited = newLastTwo + ) + } + revalidate() + } + fun proceedToConfirm() { _uiState.update { it.copy(step = Step.CONFIRM, submitError = null) } } @@ -205,10 +290,6 @@ class SellWizardViewModel @Inject constructor( _uiState.update { it.copy(step = Step.INPUT, submitError = null) } } - /** - * Place the limit sell order. On timeout we show a warning dialog (order may be - * placed but we couldn't confirm). On success the sheet is asked to dismiss. - */ fun submit() { val state = _uiState.value val amount = state.amountInput.toBigDecimalOrNull() ?: return @@ -256,7 +337,8 @@ class SellWizardViewModel @Inject constructor( viewModelScope.launch { val validations = try { validateSellOrderUseCase( - state.planId, amount, price, state.minOrderSize, state.spotPrice + state.planId, amount, price, state.minOrderSize, state.spotPrice, + state.avgBuyPrice, state.feeRate ) } catch (e: Exception) { Log.w(TAG, "validate failed: ${e.message}") From aed6a387e976066b738943d24db98b1e99f92979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 12:48:10 +0200 Subject: [PATCH 46/75] feat(sell): editable avg buy + net field + rich summary + loss banner Replaces the avg-buy InfoRow with an editable OutlinedTextField backed by SellWizardViewModel.setAvgBuyPrice / resetAvgBuyPrice, with a "Calc from plan" chip when the user has overridden. Adds the net proceeds field (3rd of the calculator triple) with profit-target presets +10/+20/+50/+100%. Summary now shows estimated fee, net profit after fee, and target progress when the plan has a target. Loss banner fires from SellValidation.LossWarning with two phrasings (below-avg vs after-fee). Inventory deficit warning surfaces import inconsistencies. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/sell/SellWizardBottomSheet.kt | 189 ++++++++++++++++-- .../app/src/main/res/values-cs/strings.xml | 13 ++ .../app/src/main/res/values/strings.xml | 13 ++ 3 files changed, 201 insertions(+), 14 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index f0bf84a..452415d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -123,20 +123,71 @@ private fun SellInputStep( Spacer(Modifier.height(12.dp)) - // Info block + // Info block (read-only context) InfoRow( stringResource(R.string.sell_wizard_spot_price), state.spotPrice?.let { "${NumberFormatters.fiat(it)} ${state.fiat}" } ?: "-" ) - InfoRow( - stringResource(R.string.sell_wizard_avg_buy), - state.avgBuyPrice?.let { "${NumberFormatters.fiat(it)} ${state.fiat}" } ?: "-" - ) InfoRow( stringResource(R.string.sell_wizard_available), "${NumberFormatters.crypto(state.availableToSell)} ${state.crypto}" ) + if (state.inventoryDeficit > BigDecimal.ZERO) { + Spacer(Modifier.height(4.dp)) + WarningBanner( + stringResource( + R.string.sell_wizard_inventory_deficit, + "${NumberFormatters.crypto(state.inventoryDeficit)} ${state.crypto}" + ) + ) + } + + // Editable avg buy price + Spacer(Modifier.height(12.dp)) + Text( + stringResource(R.string.sell_wizard_avg_buy), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(4.dp)) + OutlinedTextField( + value = state.avgBuyPriceInput, + onValueChange = vm::setAvgBuyPrice, + trailingIcon = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (state.avgBuyPriceManual && state.avgBuyPriceAuto != null) { + AssistChip( + onClick = vm::resetAvgBuyPrice, + label = { Text(stringResource(R.string.sell_wizard_avg_buy_reset)) }, + modifier = Modifier.padding(end = 8.dp) + ) + } + Text( + text = state.fiat, + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + supportingText = { + Text( + stringResource( + when { + state.avgBuyPriceAuto == null && state.avgBuyPriceInput.isBlank() -> + R.string.sell_wizard_avg_buy_helper_required + state.avgBuyPriceManual -> + R.string.sell_wizard_avg_buy_helper_manual + else -> R.string.sell_wizard_avg_buy_helper_auto + } + ) + ) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(Modifier.height(16.dp)) Text( stringResource(R.string.sell_wizard_amount), @@ -216,6 +267,52 @@ private fun SellInputStep( label = { Text("+25%") }, enabled = state.avgBuyPrice != null ) + AssistChip( + onClick = { vm.setPriceAvgPlus(50) }, + label = { Text("+50%") }, + enabled = state.avgBuyPrice != null + ) + } + + // Net fiat field (3rd of the calculator triple) + Spacer(Modifier.height(16.dp)) + Text( + stringResource(R.string.sell_wizard_net_fiat), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(4.dp)) + OutlinedTextField( + value = state.netInput, + onValueChange = vm::setNetFiat, + trailingIcon = { + Text( + text = state.fiat, + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Text( + stringResource(R.string.sell_wizard_net_preset_label), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + Row( + modifier = Modifier.padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + listOf(0.10 to "+10%", 0.20 to "+20%", 0.50 to "+50%", 1.00 to "+100%").forEach { (factor, label) -> + AssistChip( + onClick = { vm.applyNetProfitPreset(factor) }, + label = { Text(label) }, + enabled = state.avgBuyPrice != null && state.amountInput.toBigDecimalOrNull() != null + ) + } } Spacer(Modifier.height(16.dp)) @@ -228,24 +325,62 @@ private fun SellInputStep( val amountBD = state.amountInput.toBigDecimalOrNull() ?: BigDecimal.ZERO val priceBD = state.priceInput.toBigDecimalOrNull() ?: BigDecimal.ZERO val proceeds = (amountBD * priceBD).setScale(2, RoundingMode.HALF_UP) - InfoRow(stringResource(R.string.sell_wizard_proceeds), "${NumberFormatters.fiat(proceeds)} ${state.fiat}") + val feeAmount = (proceeds * state.feeRate).setScale(2, RoundingMode.HALF_UP) + val netProceeds = (proceeds - feeAmount).setScale(2, RoundingMode.HALF_UP) + InfoRow( + stringResource(R.string.sell_wizard_proceeds), + "${NumberFormatters.fiat(proceeds)} ${state.fiat}" + ) + if (state.feeRate > BigDecimal.ZERO && proceeds > BigDecimal.ZERO) { + InfoRow( + stringResource(R.string.sell_wizard_summary_fee), + "-${NumberFormatters.fiat(feeAmount)} ${state.fiat} (${state.feeRate.multiply(BigDecimal(100)).setScale(2, RoundingMode.HALF_UP).toPlainString()}%)" + ) + } state.avgBuyPrice?.let { avg -> - val profit = ((priceBD - avg) * amountBD).setScale(2, RoundingMode.HALF_UP) - val profitPct = if (avg > BigDecimal.ZERO) { - (priceBD - avg).divide(avg, 4, RoundingMode.HALF_UP) + val costBasis = amountBD * avg + val netProfit = (netProceeds - costBasis).setScale(2, RoundingMode.HALF_UP) + val netProfitPct = if (costBasis > BigDecimal.ZERO) { + netProfit.divide(costBasis, 4, RoundingMode.HALF_UP) .multiply(BigDecimal(100)) .setScale(1, RoundingMode.HALF_UP) } else BigDecimal.ZERO - val sign = if (profit >= BigDecimal.ZERO) "+" else "" + val sign = if (netProfit >= BigDecimal.ZERO) "+" else "" InfoRow( - label = stringResource(R.string.sell_wizard_profit_vs_avg), - value = "$sign${NumberFormatters.fiat(profit)} ${state.fiat} ($sign${profitPct.toPlainString()}%)", + label = stringResource(R.string.sell_wizard_summary_net_profit), + value = "$sign${NumberFormatters.fiat(netProfit)} ${state.fiat} ($sign${netProfitPct.toPlainString()}%)", color = when { - profit > BigDecimal.ZERO -> successColor() - profit < BigDecimal.ZERO -> Error + netProfit > BigDecimal.ZERO -> successColor() + netProfit < BigDecimal.ZERO -> Error else -> null } ) + + // Target progress + state.targetProfitAmount?.takeIf { it > BigDecimal.ZERO }?.let { target -> + val totalProgress = state.realizedPnLSoFar + netProfit + val pct = (totalProgress.toDouble() / target.toDouble()).coerceAtLeast(0.0) + InfoRow( + stringResource(R.string.sell_wizard_summary_target_progress), + "${NumberFormatters.fiat(totalProgress)} / ${NumberFormatters.fiat(target)} ${state.fiat} (${"%.0f".format(pct * 100)}%)" + ) + } + } + + // Loss banner from validations + state.validations.filterIsInstance().firstOrNull()?.let { loss -> + Spacer(Modifier.height(8.dp)) + val isPriceBelowAvg = state.priceInput.toBigDecimalOrNull()?.let { p -> + state.avgBuyPrice?.let { avg -> p < avg } + } ?: false + LossBanner( + stringResource( + if (isPriceBelowAvg) R.string.sell_wizard_loss_below_buy + else R.string.sell_wizard_loss_after_fee, + NumberFormatters.fiat(loss.lossFiat), + state.fiat + ) + ) } // Validations @@ -451,6 +586,32 @@ internal fun InfoBanner(text: String) { } } +@Composable +internal fun LossBanner(text: String) { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.Top) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } +} + @Composable internal fun WarningBanner(text: String) { Surface( diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 71bd64f..46fb56c 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -1004,4 +1004,17 @@ Odeslat Nelze ověřit stav příkazu Spojení s burzou selhalo nebo vypršelo. Příkaz mohl být odeslán, ale nelze to potvrdit. Zkontroluj otevřené příkazy na burze přes web a v případě potřeby zruš duplicitu. + Spočítáno z plánu + Zadáno ručně + Zatím žádné nákupy (nebo vše prodáno) - zadej ručně + Spočítat z plánu + Čistý výnos (po fee) + Zisk z této transakce: + Odhad fee + Čistý zisk (po fee) + Po tomto prodeji + Postup k cíli plánu + Prodáváš pod nákupní cenou: ztráta %1$s %2$s + Po fee jdeš do ztráty: %1$s %2$s + Inventář nesedí (chybí %1$s) - zadej průměrnou nákupní cenu ručně diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 4604cc6..411eccf 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -998,4 +998,17 @@ Submit Cannot verify order status Connection to the exchange failed or timed out. The order may have been submitted, but cannot be confirmed. Check open orders on the exchange via web and cancel any duplicates if needed. + Auto-calculated from this plan + Manually entered + No buys yet (or all sold) - enter manually + Calc from plan + Net proceeds (after fee) + Profit on this transaction: + Estimated fee + Net profit (after fee) + After this sell + Plan target progress + Selling below buy price: %1$s %2$s loss + After fee, this is a loss: %1$s %2$s + Inventory mismatch (%1$s missing) - enter avg buy price manually From a00006304b28f36abcb8beca7617c5fd97514697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 12:50:34 +0200 Subject: [PATCH 47/75] feat(sell): PlaceLadderSellUseCase + LadderGenerator helper PlaceLadderSellUseCase iterates orders sequentially, returning AllPlaced or PartialFailure on first error. No auto-rollback - caller surfaces the partial result and lets the user decide. LadderGenerator.generate produces N orders linearly distributed across [from..to] with two amount modes: EQUAL_CRYPTO (each order sells total/N) or EQUAL_FIAT (each order generates same gross fiat, amounts rescaled to match total). 3 unit tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../domain/usecase/PlaceLadderSellUseCase.kt | 68 +++++++++++++++++++ .../screens/plans/sell/LadderGenerator.kt | 67 ++++++++++++++++++ .../screens/plans/sell/LadderGeneratorTest.kt | 50 ++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/LadderGenerator.kt create mode 100644 accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/LadderGeneratorTest.kt diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt new file mode 100644 index 0000000..034d403 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt @@ -0,0 +1,68 @@ +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.data.local.toEntity +import com.accbot.dca.domain.model.DcaResult +import com.accbot.dca.exchange.ExchangeApiFactory +import java.math.BigDecimal +import javax.inject.Inject + +data class LadderOrder(val cryptoAmount: BigDecimal, val limitPrice: BigDecimal) + +sealed class LadderResult { + data class AllPlaced(val placedTxIds: List) : LadderResult() + data class PartialFailure( + val placedTxIds: List, + val failedAtIndex: Int, + val totalCount: Int, + val reason: String + ) : LadderResult() +} + +/** + * Place a sequence of limit sell orders for a single plan. On the first failure we stop + * and report partial success - already placed orders stay on the exchange and as PENDING + * transactions in the DB. The caller (UI) can ask the user whether to retry the rest or + * cancel placed orders manually via the existing cancel flow on plan detail. + */ +class PlaceLadderSellUseCase @Inject constructor( + private val database: DcaDatabase, + private val credentialsStore: CredentialsStore, + private val exchangeApiFactory: ExchangeApiFactory, + private val userPreferences: UserPreferences, + private val resolvePendingTransactionsUseCase: ResolvePendingTransactionsUseCase +) { + suspend operator fun invoke(planId: Long, orders: List): LadderResult { + if (orders.size < 2) return LadderResult.PartialFailure( + emptyList(), 0, orders.size, "Ladder requires at least 2 orders" + ) + + val plan = database.dcaPlanDao().getPlanById(planId) + ?: return LadderResult.PartialFailure(emptyList(), 0, orders.size, "Plan not found") + val credentials = credentialsStore.getCredentials( + plan.connectionId, userPreferences.isSandboxMode() + ) ?: return LadderResult.PartialFailure(emptyList(), 0, orders.size, "Missing credentials") + + val api = exchangeApiFactory.create(credentials) + val placed = mutableListOf() + + orders.forEachIndexed { idx, order -> + val result = api.limitSell(plan.crypto, plan.fiat, order.cryptoAmount, order.limitPrice) + when (result) { + is DcaResult.Success -> { + val tx = result.transaction.copy(planId = planId, connectionId = plan.connectionId) + val id = database.transactionDao().insertTransaction(tx.toEntity()) + placed += id + } + is DcaResult.Error -> { + return LadderResult.PartialFailure(placed, idx, orders.size, result.message) + } + } + } + + try { resolvePendingTransactionsUseCase() } catch (_: Exception) {} + return LadderResult.AllPlaced(placed) + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/LadderGenerator.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/LadderGenerator.kt new file mode 100644 index 0000000..70490c4 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/LadderGenerator.kt @@ -0,0 +1,67 @@ +package com.accbot.dca.presentation.screens.plans.sell + +import com.accbot.dca.domain.usecase.LadderOrder +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Generates N limit-sell orders linearly distributed across a price range. + * + * Two amount distribution modes: + * - [AmountMode.EQUAL_CRYPTO]: each order sells `total / N` BTC. Simplest and most + * predictable for min-order-size validation. + * - [AmountMode.EQUAL_FIAT]: each order generates the same gross fiat. Cheaper orders + * sell larger crypto amounts. After per-order rounding the sum is rescaled to match + * `total` so the user actually sells the requested total. + */ +object LadderGenerator { + + enum class AmountMode { EQUAL_CRYPTO, EQUAL_FIAT } + + fun generate( + totalAmount: BigDecimal, + from: BigDecimal, + to: BigDecimal, + count: Int, + mode: AmountMode + ): List { + require(count >= 2) { "count >= 2" } + require(totalAmount > BigDecimal.ZERO) { "totalAmount > 0" } + require(from > BigDecimal.ZERO && to > BigDecimal.ZERO) { "prices > 0" } + + val n = BigDecimal(count) + val prices = (0 until count).map { i -> + val step = (to - from) * BigDecimal(i) / BigDecimal(count - 1) + (from + step).setScale(2, RoundingMode.HALF_UP) + } + + return when (mode) { + AmountMode.EQUAL_CRYPTO -> { + val per = totalAmount.divide(n, 8, RoundingMode.DOWN) + val drobky = totalAmount - per * n + prices.mapIndexed { i, p -> + val a = if (i == count - 1) per + drobky else per + LadderOrder(a, p) + } + } + AmountMode.EQUAL_FIAT -> { + // Target gross per order = totalAmount * avgPrice / N. Use the simple + // arithmetic mean of prices as the fair "expected price" so the resulting + // crypto amounts sum close to total before rescaling. + val avgPrice = prices.fold(BigDecimal.ZERO) { acc, x -> acc + x } + .divide(n, 8, RoundingMode.HALF_UP) + val perOrderGross = (totalAmount * avgPrice).divide(n, 8, RoundingMode.HALF_UP) + val rawAmounts = prices.map { p -> + perOrderGross.divide(p, 8, RoundingMode.DOWN) + } + val sumRaw = rawAmounts.fold(BigDecimal.ZERO) { acc, x -> acc + x } + val scale = if (sumRaw > BigDecimal.ZERO) + totalAmount.divide(sumRaw, 12, RoundingMode.HALF_UP) + else BigDecimal.ONE + rawAmounts.mapIndexed { i, a -> + LadderOrder((a * scale).setScale(8, RoundingMode.DOWN), prices[i]) + } + } + } + } +} diff --git a/accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/LadderGeneratorTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/LadderGeneratorTest.kt new file mode 100644 index 0000000..7a25356 --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/presentation/screens/plans/sell/LadderGeneratorTest.kt @@ -0,0 +1,50 @@ +package com.accbot.dca.presentation.screens.plans.sell + +import com.accbot.dca.presentation.screens.plans.sell.LadderGenerator.AmountMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.math.BigDecimal + +class LadderGeneratorTest { + + @Test + fun `equal crypto - 5 orderu po 0,2 BTC v rozsahu cen`() { + val orders = LadderGenerator.generate( + totalAmount = BigDecimal("1"), + from = BigDecimal("2000000"), + to = BigDecimal("2400000"), + count = 5, + mode = AmountMode.EQUAL_CRYPTO + ) + assertEquals(5, orders.size) + assertEquals(0, BigDecimal("2000000.00").compareTo(orders[0].limitPrice)) + assertEquals(0, BigDecimal("2400000.00").compareTo(orders[4].limitPrice)) + // sum amounts == totalAmount (with the last order absorbing rounding drobky) + val total = orders.fold(BigDecimal.ZERO) { acc, o -> acc + o.cryptoAmount } + assertEquals(0, BigDecimal("1").compareTo(total)) + } + + @Test + fun `equal fiat - levnejsi ordery prodavaji vic crypta`() { + val orders = LadderGenerator.generate( + totalAmount = BigDecimal("1"), + from = BigDecimal("1000000"), + to = BigDecimal("2000000"), + count = 4, + mode = AmountMode.EQUAL_FIAT + ) + assertEquals(4, orders.size) + assertTrue( + "cheapest order should sell more crypto than most expensive", + orders[0].cryptoAmount > orders[3].cryptoAmount + ) + } + + @Test(expected = IllegalArgumentException::class) + fun `count menez nez 2 hodi exception`() { + LadderGenerator.generate( + BigDecimal("1"), BigDecimal("1000"), BigDecimal("2000"), 1, AmountMode.EQUAL_CRYPTO + ) + } +} From 589b027faa65440d38eb7edc6c44ad708586c5d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 12:54:48 +0200 Subject: [PATCH 48/75] feat(sell): ladder mode wizard UI + submit flow Checkbox toggles between single and ladder modes. Ladder UI shows From/To range with toggle (Profit % vs Price), order count, amount-mode toggle (Equal crypto vs Equal fiat), plus a live preview table with per-order amount, price, profit % and net fiat. Submit dispatches via PlaceLadderSellUseCase; partial-success dialog reports placed/total/reason. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/sell/SellWizardBottomSheet.kt | 157 +++++++++++++++++ .../screens/plans/sell/SellWizardViewModel.kt | 165 +++++++++++++++++- .../app/src/main/res/values-cs/strings.xml | 11 ++ .../app/src/main/res/values/strings.xml | 11 ++ 4 files changed, 340 insertions(+), 4 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index 452415d..773c131 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -88,6 +88,28 @@ fun SellWizardBottomSheet( SellWizardViewModel.Step.CONFIRM -> SellConfirmStep(state, viewModel) } } + + state.ladderOutcome?.let { outcome -> + AlertDialog( + onDismissRequest = viewModel::consumeLadderOutcome, + title = { Text(stringResource(R.string.sell_wizard_ladder_enable)) }, + text = { + Text( + if (outcome.reason == null) { + stringResource(R.string.sell_wizard_ladder_outcome_all, outcome.placed) + } else { + stringResource( + R.string.sell_wizard_ladder_outcome_partial, + outcome.placed, outcome.total, outcome.reason + ) + } + ) + }, + confirmButton = { + Button(onClick = viewModel::consumeLadderOutcome) { Text("OK") } + } + ) + } } @Composable @@ -222,6 +244,21 @@ private fun SellInputStep( } } + // Ladder mode toggle + Spacer(Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + androidx.compose.material3.Checkbox( + checked = state.ladderEnabled, + onCheckedChange = vm::setLadderEnabled + ) + Text(stringResource(R.string.sell_wizard_ladder_enable)) + } + + if (state.ladderEnabled) { + LadderControls(state = state, vm = vm) + } + + if (!state.ladderEnabled) { Spacer(Modifier.height(16.dp)) Text( stringResource(R.string.sell_wizard_limit_price), @@ -314,6 +351,7 @@ private fun SellInputStep( ) } } + } // end if !ladderEnabled Spacer(Modifier.height(16.dp)) Text( @@ -586,6 +624,125 @@ internal fun InfoBanner(text: String) { } } +@Composable +private fun LadderControls(state: SellWizardViewModel.UiState, vm: SellWizardViewModel) { + val avg = state.avgBuyPrice + Spacer(Modifier.height(12.dp)) + + // Range mode toggle + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf( + SellWizardViewModel.LadderRangeMode.PROFIT_PCT to stringResource(R.string.sell_wizard_ladder_range_profit), + SellWizardViewModel.LadderRangeMode.PRICE to stringResource(R.string.sell_wizard_ladder_range_price) + ).forEach { (mode, label) -> + AssistChip( + onClick = { vm.setLadderRangeMode(mode) }, + label = { Text(label) }, + colors = if (state.ladderRangeMode == mode) { + androidx.compose.material3.AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + } else androidx.compose.material3.AssistChipDefaults.assistChipColors() + ) + } + } + + // From / To + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = state.ladderFromInput, + onValueChange = vm::setLadderFrom, + label = { Text(stringResource(R.string.sell_wizard_ladder_from)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f), + singleLine = true + ) + OutlinedTextField( + value = state.ladderToInput, + onValueChange = vm::setLadderTo, + label = { Text(stringResource(R.string.sell_wizard_ladder_to)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f), + singleLine = true + ) + } + + // Count + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = state.ladderCountInput, + onValueChange = vm::setLadderCount, + label = { Text(stringResource(R.string.sell_wizard_ladder_count)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // Amount mode toggle + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf( + LadderGenerator.AmountMode.EQUAL_CRYPTO to stringResource(R.string.sell_wizard_ladder_amount_equal_crypto), + LadderGenerator.AmountMode.EQUAL_FIAT to stringResource(R.string.sell_wizard_ladder_amount_equal_fiat) + ).forEach { (mode, label) -> + AssistChip( + onClick = { vm.setLadderAmountMode(mode) }, + label = { Text(label) }, + colors = if (state.ladderAmountMode == mode) { + androidx.compose.material3.AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + } else androidx.compose.material3.AssistChipDefaults.assistChipColors() + ) + } + } + + state.ladderHardError?.let { err -> + Spacer(Modifier.height(8.dp)) + Text(err, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + + if (state.ladderPreview.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + Column(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth()) { + Text("#", modifier = Modifier.weight(0.4f), style = MaterialTheme.typography.labelSmall) + Text(stringResource(R.string.sell_wizard_amount), modifier = Modifier.weight(1.4f), style = MaterialTheme.typography.labelSmall) + Text(stringResource(R.string.sell_wizard_limit_price), modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.labelSmall) + Text("Profit", modifier = Modifier.weight(1f), style = MaterialTheme.typography.labelSmall) + Text("Net", modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.labelSmall) + } + androidx.compose.material3.HorizontalDivider() + var totalNet = BigDecimal.ZERO + state.ladderPreview.forEachIndexed { i, o -> + val gross = o.cryptoAmount * o.limitPrice + val net = gross * (BigDecimal.ONE - state.feeRate) + val profitPctText = if (avg != null && avg > BigDecimal.ZERO) { + val pct = (o.limitPrice - avg).divide(avg, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)).setScale(1, RoundingMode.HALF_UP) + "${if (pct.signum() >= 0) "+" else ""}${pct.toPlainString()}%" + } else "-" + totalNet += if (avg != null) net - o.cryptoAmount * avg else net + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text("${i + 1}", modifier = Modifier.weight(0.4f), style = MaterialTheme.typography.bodySmall) + Text(NumberFormatters.crypto(o.cryptoAmount), modifier = Modifier.weight(1.4f), style = MaterialTheme.typography.bodySmall) + Text(NumberFormatters.fiat(o.limitPrice), modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.bodySmall) + Text(profitPctText, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) + Text(NumberFormatters.fiat(net.setScale(2, RoundingMode.HALF_UP)), modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.bodySmall) + } + } + androidx.compose.material3.HorizontalDivider() + Text( + "${stringResource(R.string.sell_wizard_ladder_preview_total)}: ${NumberFormatters.fiat(totalNet.setScale(2, RoundingMode.HALF_UP))} ${state.fiat}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 4.dp) + ) + } + } +} + @Composable internal fun LossBanner(text: String) { Surface( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt index 56f872a..b284d78 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt @@ -12,6 +12,9 @@ import com.accbot.dca.domain.model.TransactionSide import com.accbot.dca.domain.model.TransactionStatus import com.accbot.dca.domain.usecase.CalculatePlanCostBasisUseCase import com.accbot.dca.domain.usecase.CalculatePlanPnLUseCase +import com.accbot.dca.domain.usecase.LadderOrder +import com.accbot.dca.domain.usecase.LadderResult +import com.accbot.dca.domain.usecase.PlaceLadderSellUseCase import com.accbot.dca.domain.usecase.PlaceLimitSellUseCase import com.accbot.dca.domain.usecase.SellValidation import com.accbot.dca.domain.usecase.ValidateSellOrderUseCase @@ -42,6 +45,7 @@ import javax.inject.Inject class SellWizardViewModel @Inject constructor( private val validateSellOrderUseCase: ValidateSellOrderUseCase, private val placeLimitSellUseCase: PlaceLimitSellUseCase, + private val placeLadderSellUseCase: PlaceLadderSellUseCase, private val calculatePlanPnLUseCase: CalculatePlanPnLUseCase, private val calculatePlanCostBasisUseCase: CalculatePlanCostBasisUseCase, private val database: DcaDatabase, @@ -51,6 +55,10 @@ class SellWizardViewModel @Inject constructor( ) : ViewModel() { enum class Step { INPUT, CONFIRM } + enum class LadderRangeMode { PRICE, PROFIT_PCT } + + /** Snapshot of partial ladder result so the UI can present a dialog after submit. */ + data class LadderSubmitOutcome(val placed: Int, val total: Int, val reason: String?) data class UiState( val planId: Long = 0, @@ -77,6 +85,15 @@ class SellWizardViewModel @Inject constructor( val realizedPnLSoFar: BigDecimal = BigDecimal.ZERO, val inventoryDeficit: BigDecimal = BigDecimal.ZERO, val validations: List = emptyList(), + val ladderEnabled: Boolean = false, + val ladderRangeMode: LadderRangeMode = LadderRangeMode.PROFIT_PCT, + val ladderFromInput: String = "", + val ladderToInput: String = "", + val ladderCountInput: String = "5", + val ladderAmountMode: LadderGenerator.AmountMode = LadderGenerator.AmountMode.EQUAL_CRYPTO, + val ladderPreview: List = emptyList(), + val ladderHardError: String? = null, + val ladderOutcome: LadderSubmitOutcome? = null, val step: Step = Step.INPUT, val initializing: Boolean = true, val submitting: Boolean = false, @@ -89,10 +106,15 @@ class SellWizardViewModel @Inject constructor( get() = if (avgBuyPriceManual) avgBuyPriceInput.toBigDecimalOrNull() else avgBuyPriceAuto val canProceed: Boolean - get() = validations.none { it is SellValidation.HardError } && - amountInput.isNotBlank() && priceInput.isNotBlank() && - amountInput.toBigDecimalOrNull() != null && - priceInput.toBigDecimalOrNull() != null + get() = if (ladderEnabled) { + ladderHardError == null && ladderPreview.size >= 2 && + amountInput.toBigDecimalOrNull() != null + } else { + validations.none { it is SellValidation.HardError } && + amountInput.isNotBlank() && priceInput.isNotBlank() && + amountInput.toBigDecimalOrNull() != null && + priceInput.toBigDecimalOrNull() != null + } } private val _uiState = MutableStateFlow(UiState()) @@ -238,6 +260,7 @@ class SellWizardViewModel @Inject constructor( st.copy(avgBuyPriceInput = value, avgBuyPriceManual = isManual) } revalidate() + recomputeLadderPreview() } fun resetAvgBuyPrice() { @@ -248,6 +271,7 @@ class SellWizardViewModel @Inject constructor( ) } revalidate() + recomputeLadderPreview() } private fun updateCalculatorField(field: SellCalculatorMath.Field, text: String) { @@ -280,6 +304,135 @@ class SellWizardViewModel @Inject constructor( ) } revalidate() + if (field == SellCalculatorMath.Field.AMOUNT) recomputeLadderPreview() + } + + // --- Ladder handlers --- + + fun setLadderEnabled(enabled: Boolean) { + _uiState.update { it.copy(ladderEnabled = enabled, ladderOutcome = null) } + recomputeLadderPreview() + } + + fun setLadderRangeMode(mode: LadderRangeMode) { + _uiState.update { it.copy(ladderRangeMode = mode, ladderFromInput = "", ladderToInput = "") } + recomputeLadderPreview() + } + + fun setLadderFrom(value: String) { + _uiState.update { it.copy(ladderFromInput = value) } + recomputeLadderPreview() + } + + fun setLadderTo(value: String) { + _uiState.update { it.copy(ladderToInput = value) } + recomputeLadderPreview() + } + + fun setLadderCount(value: String) { + _uiState.update { it.copy(ladderCountInput = value) } + recomputeLadderPreview() + } + + fun setLadderAmountMode(mode: LadderGenerator.AmountMode) { + _uiState.update { it.copy(ladderAmountMode = mode) } + recomputeLadderPreview() + } + + private fun recomputeLadderPreview() { + val st = _uiState.value + if (!st.ladderEnabled) { + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = null) } + return + } + val total = st.amountInput.toBigDecimalOrNull() + val count = st.ladderCountInput.toIntOrNull() + val avg = st.avgBuyPrice + val (from, to) = when (st.ladderRangeMode) { + LadderRangeMode.PRICE -> { + st.ladderFromInput.toBigDecimalOrNull() to st.ladderToInput.toBigDecimalOrNull() + } + LadderRangeMode.PROFIT_PCT -> { + if (avg == null) { + _uiState.update { + it.copy(ladderPreview = emptyList(), ladderHardError = "Pro profit % musi byt avg buy price") + } + return + } + val fPct = st.ladderFromInput.toBigDecimalOrNull() + val tPct = st.ladderToInput.toBigDecimalOrNull() + val f = fPct?.let { avg * (BigDecimal.ONE + it.divide(BigDecimal(100), 8, RoundingMode.HALF_UP)) } + val t = tPct?.let { avg * (BigDecimal.ONE + it.divide(BigDecimal(100), 8, RoundingMode.HALF_UP)) } + f to t + } + } + + if (total == null || count == null || from == null || to == null) { + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = null) } + return + } + if (total <= BigDecimal.ZERO) { + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = "Mnozstvi > 0") } + return + } + if (count < 2) { + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = "Pocet >= 2") } + return + } + if (to <= from || from <= BigDecimal.ZERO) { + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = "Do > Od, oba > 0") } + return + } + if (total > st.availableToSell) { + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = "Nemas tolik k dispozici") } + return + } + + val preview = LadderGenerator.generate(total, from, to, count, st.ladderAmountMode) + val minPerOrder = preview.minOf { it.cryptoAmount } + val hardError = if (minPerOrder < st.minOrderSize) { + "Order ${minPerOrder} < min ${st.minOrderSize}" + } else null + + _uiState.update { it.copy(ladderPreview = preview, ladderHardError = hardError) } + } + + fun submitLadder() { + val st = _uiState.value + if (!st.ladderEnabled || st.ladderPreview.size < 2) return + viewModelScope.launch { + _uiState.update { it.copy(submitting = true, submitError = null, ladderOutcome = null) } + val result = withTimeoutOrNull(30_000L) { + placeLadderSellUseCase(st.planId, st.ladderPreview) + } + when (result) { + null -> _uiState.update { it.copy(submitting = false, showTimeoutDialog = true) } + is LadderResult.AllPlaced -> _uiState.update { + it.copy( + submitting = false, + ladderOutcome = LadderSubmitOutcome( + placed = result.placedTxIds.size, + total = result.placedTxIds.size, + reason = null + ) + ) + } + is LadderResult.PartialFailure -> _uiState.update { + it.copy( + submitting = false, + ladderOutcome = LadderSubmitOutcome( + placed = result.placedTxIds.size, + total = result.totalCount, + reason = result.reason + ) + ) + } + } + } + } + + fun consumeLadderOutcome() { + _uiState.update { it.copy(ladderOutcome = null, dismissRequested = true) } } fun proceedToConfirm() { @@ -292,6 +445,10 @@ class SellWizardViewModel @Inject constructor( fun submit() { val state = _uiState.value + if (state.ladderEnabled) { + submitLadder() + return + } val amount = state.amountInput.toBigDecimalOrNull() ?: return val price = state.priceInput.toBigDecimalOrNull() ?: return diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 46fb56c..9617e95 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -1017,4 +1017,15 @@ Prodáváš pod nákupní cenou: ztráta %1$s %2$s Po fee jdeš do ztráty: %1$s %2$s Inventář nesedí (chybí %1$s) - zadej průměrnou nákupní cenu ručně + Vytvořit více sell orderů (ladder) + Od + Do + Počet orderů + Cena + Profit % + Stejné množství + Stejný výnos + Celkem při plném fillu + Vytvořeno všech %1$d orderů + Vytvořeno %1$d z %2$d. Zastaveno: %3$s diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 411eccf..d667652 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -1011,4 +1011,15 @@ Selling below buy price: %1$s %2$s loss After fee, this is a loss: %1$s %2$s Inventory mismatch (%1$s missing) - enter avg buy price manually + Create multiple sell orders (ladder) + From + To + Number of orders + Price + Profit % + Equal crypto + Equal fiat + Total at full fill + All %1$d orders placed + Placed %1$d of %2$d. Stopped: %3$s From df2fcb9ac7c5d0101798f84987b4b9fa8f882407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 12:59:16 +0200 Subject: [PATCH 49/75] feat(sell): ladder confirm step shows read-only preview table LadderPreviewTable extracted as a reusable composable so the CONFIRM step can render the ladder preview without exposing edit handlers from the input-step LadderControls. Single mode keeps amount/limit/proceeds SummaryRows. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/sell/SellWizardBottomSheet.kt | 102 +++++++++++------- 1 file changed, 66 insertions(+), 36 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index 773c131..e06648e 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -486,9 +486,24 @@ private fun SellConfirmStep( SummaryRow(stringResource(R.string.sell_wizard_confirm_exchange), state.exchangeName) SummaryRow(stringResource(R.string.sell_wizard_confirm_plan), state.planName) SummaryRow(stringResource(R.string.sell_wizard_confirm_side), stringResource(R.string.sell_wizard_confirm_side_sell)) - SummaryRow(stringResource(R.string.sell_wizard_confirm_amount), "${NumberFormatters.crypto(amountBD)} ${state.crypto}") - SummaryRow(stringResource(R.string.sell_wizard_confirm_limit_price), "${NumberFormatters.fiat(priceBD)} ${state.fiat}") - SummaryRow(stringResource(R.string.sell_wizard_confirm_proceeds), "${NumberFormatters.fiat(proceeds)} ${state.fiat}") + if (state.ladderEnabled) { + SummaryRow( + stringResource(R.string.sell_wizard_ladder_count), + "${state.ladderPreview.size}" + ) + SummaryRow(stringResource(R.string.sell_wizard_confirm_amount), "${NumberFormatters.crypto(amountBD)} ${state.crypto}") + Spacer(Modifier.height(8.dp)) + LadderPreviewTable( + preview = state.ladderPreview, + avg = state.avgBuyPrice, + feeRate = state.feeRate, + fiat = state.fiat + ) + } else { + SummaryRow(stringResource(R.string.sell_wizard_confirm_amount), "${NumberFormatters.crypto(amountBD)} ${state.crypto}") + SummaryRow(stringResource(R.string.sell_wizard_confirm_limit_price), "${NumberFormatters.fiat(priceBD)} ${state.fiat}") + SummaryRow(stringResource(R.string.sell_wizard_confirm_proceeds), "${NumberFormatters.fiat(proceeds)} ${state.fiat}") + } Spacer(Modifier.height(16.dp)) WarningBanner( @@ -705,41 +720,56 @@ private fun LadderControls(state: SellWizardViewModel.UiState, vm: SellWizardVie if (state.ladderPreview.isNotEmpty()) { Spacer(Modifier.height(12.dp)) - Column(modifier = Modifier.fillMaxWidth()) { - Row(modifier = Modifier.fillMaxWidth()) { - Text("#", modifier = Modifier.weight(0.4f), style = MaterialTheme.typography.labelSmall) - Text(stringResource(R.string.sell_wizard_amount), modifier = Modifier.weight(1.4f), style = MaterialTheme.typography.labelSmall) - Text(stringResource(R.string.sell_wizard_limit_price), modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.labelSmall) - Text("Profit", modifier = Modifier.weight(1f), style = MaterialTheme.typography.labelSmall) - Text("Net", modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.labelSmall) - } - androidx.compose.material3.HorizontalDivider() - var totalNet = BigDecimal.ZERO - state.ladderPreview.forEachIndexed { i, o -> - val gross = o.cryptoAmount * o.limitPrice - val net = gross * (BigDecimal.ONE - state.feeRate) - val profitPctText = if (avg != null && avg > BigDecimal.ZERO) { - val pct = (o.limitPrice - avg).divide(avg, 4, RoundingMode.HALF_UP) - .multiply(BigDecimal(100)).setScale(1, RoundingMode.HALF_UP) - "${if (pct.signum() >= 0) "+" else ""}${pct.toPlainString()}%" - } else "-" - totalNet += if (avg != null) net - o.cryptoAmount * avg else net - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { - Text("${i + 1}", modifier = Modifier.weight(0.4f), style = MaterialTheme.typography.bodySmall) - Text(NumberFormatters.crypto(o.cryptoAmount), modifier = Modifier.weight(1.4f), style = MaterialTheme.typography.bodySmall) - Text(NumberFormatters.fiat(o.limitPrice), modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.bodySmall) - Text(profitPctText, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) - Text(NumberFormatters.fiat(net.setScale(2, RoundingMode.HALF_UP)), modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.bodySmall) - } + LadderPreviewTable( + preview = state.ladderPreview, + avg = avg, + feeRate = state.feeRate, + fiat = state.fiat + ) + } +} + +@Composable +private fun LadderPreviewTable( + preview: List, + avg: BigDecimal?, + feeRate: BigDecimal, + fiat: String +) { + Column(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth()) { + Text("#", modifier = Modifier.weight(0.4f), style = MaterialTheme.typography.labelSmall) + Text(stringResource(R.string.sell_wizard_amount), modifier = Modifier.weight(1.4f), style = MaterialTheme.typography.labelSmall) + Text(stringResource(R.string.sell_wizard_limit_price), modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.labelSmall) + Text("Profit", modifier = Modifier.weight(1f), style = MaterialTheme.typography.labelSmall) + Text("Net", modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.labelSmall) + } + androidx.compose.material3.HorizontalDivider() + var totalNet = BigDecimal.ZERO + preview.forEachIndexed { i, o -> + val gross = o.cryptoAmount * o.limitPrice + val net = gross * (BigDecimal.ONE - feeRate) + val profitPctText = if (avg != null && avg > BigDecimal.ZERO) { + val pct = (o.limitPrice - avg).divide(avg, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)).setScale(1, RoundingMode.HALF_UP) + "${if (pct.signum() >= 0) "+" else ""}${pct.toPlainString()}%" + } else "-" + totalNet += if (avg != null) net - o.cryptoAmount * avg else net + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text("${i + 1}", modifier = Modifier.weight(0.4f), style = MaterialTheme.typography.bodySmall) + Text(NumberFormatters.crypto(o.cryptoAmount), modifier = Modifier.weight(1.4f), style = MaterialTheme.typography.bodySmall) + Text(NumberFormatters.fiat(o.limitPrice), modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.bodySmall) + Text(profitPctText, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) + Text(NumberFormatters.fiat(net.setScale(2, RoundingMode.HALF_UP)), modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.bodySmall) } - androidx.compose.material3.HorizontalDivider() - Text( - "${stringResource(R.string.sell_wizard_ladder_preview_total)}: ${NumberFormatters.fiat(totalNet.setScale(2, RoundingMode.HALF_UP))} ${state.fiat}", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(top = 4.dp) - ) } + androidx.compose.material3.HorizontalDivider() + Text( + "${stringResource(R.string.sell_wizard_ladder_preview_total)}: ${NumberFormatters.fiat(totalNet.setScale(2, RoundingMode.HALF_UP))} $fiat", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 4.dp) + ) } } From 1bf345378960e0b307e807952cde5d1852448a04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 13:29:51 +0200 Subject: [PATCH 50/75] fix(sell): wizard polish - theme, label, breakeven i18n, Czech % - ModalBottomSheet uses MaterialTheme surface (was default surfaceContainerLow, rendered gray instead of the app's dark navy in dark mode). - 'Amount' heading now reads 'Amount to sell' / 'Mnozstvi k prodeji'. - 'Breakeven' chip now uses sell_wizard_chip_breakeven string (cs: 'Bez ztraty', en: 'Breakeven'). - All percent values get a space before the % per Czech typography (e.g. '+10 %' instead of '+10%'). Affects amount chips, price chips, net presets, fee display, profit pct in summary and ladder preview, and target progress. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/sell/SellWizardBottomSheet.kt | 21 ++++++++++--------- .../app/src/main/res/values-cs/strings.xml | 3 ++- .../app/src/main/res/values/strings.xml | 3 ++- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index e06648e..c256d8c 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -81,6 +81,7 @@ fun SellWizardBottomSheet( ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxHeight(0.95f) ) { when (state.step) { @@ -236,7 +237,7 @@ private fun SellInputStep( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { val allLabel = stringResource(R.string.sell_wizard_amount_all) - listOf(25 to "25%", 50 to "50%", 75 to "75%", 100 to allLabel).forEach { (pct, label) -> + listOf(25 to "25 %", 50 to "50 %", 75 to "75 %", 100 to allLabel).forEach { (pct, label) -> AssistChip( onClick = { vm.setAmountPct(pct) }, label = { Text(label) } @@ -291,22 +292,22 @@ private fun SellInputStep( ) AssistChip( onClick = vm::setPriceBreakeven, - label = { Text("Breakeven") }, + label = { Text(stringResource(R.string.sell_wizard_chip_breakeven)) }, enabled = state.avgBuyPrice != null ) AssistChip( onClick = { vm.setPriceAvgPlus(10) }, - label = { Text("+10%") }, + label = { Text("+10 %") }, enabled = state.avgBuyPrice != null ) AssistChip( onClick = { vm.setPriceAvgPlus(25) }, - label = { Text("+25%") }, + label = { Text("+25 %") }, enabled = state.avgBuyPrice != null ) AssistChip( onClick = { vm.setPriceAvgPlus(50) }, - label = { Text("+50%") }, + label = { Text("+50 %") }, enabled = state.avgBuyPrice != null ) } @@ -343,7 +344,7 @@ private fun SellInputStep( modifier = Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - listOf(0.10 to "+10%", 0.20 to "+20%", 0.50 to "+50%", 1.00 to "+100%").forEach { (factor, label) -> + listOf(0.10 to "+10 %", 0.20 to "+20 %", 0.50 to "+50 %", 1.00 to "+100 %").forEach { (factor, label) -> AssistChip( onClick = { vm.applyNetProfitPreset(factor) }, label = { Text(label) }, @@ -372,7 +373,7 @@ private fun SellInputStep( if (state.feeRate > BigDecimal.ZERO && proceeds > BigDecimal.ZERO) { InfoRow( stringResource(R.string.sell_wizard_summary_fee), - "-${NumberFormatters.fiat(feeAmount)} ${state.fiat} (${state.feeRate.multiply(BigDecimal(100)).setScale(2, RoundingMode.HALF_UP).toPlainString()}%)" + "-${NumberFormatters.fiat(feeAmount)} ${state.fiat} (${state.feeRate.multiply(BigDecimal(100)).setScale(2, RoundingMode.HALF_UP).toPlainString()} %)" ) } state.avgBuyPrice?.let { avg -> @@ -386,7 +387,7 @@ private fun SellInputStep( val sign = if (netProfit >= BigDecimal.ZERO) "+" else "" InfoRow( label = stringResource(R.string.sell_wizard_summary_net_profit), - value = "$sign${NumberFormatters.fiat(netProfit)} ${state.fiat} ($sign${netProfitPct.toPlainString()}%)", + value = "$sign${NumberFormatters.fiat(netProfit)} ${state.fiat} ($sign${netProfitPct.toPlainString()} %)", color = when { netProfit > BigDecimal.ZERO -> successColor() netProfit < BigDecimal.ZERO -> Error @@ -400,7 +401,7 @@ private fun SellInputStep( val pct = (totalProgress.toDouble() / target.toDouble()).coerceAtLeast(0.0) InfoRow( stringResource(R.string.sell_wizard_summary_target_progress), - "${NumberFormatters.fiat(totalProgress)} / ${NumberFormatters.fiat(target)} ${state.fiat} (${"%.0f".format(pct * 100)}%)" + "${NumberFormatters.fiat(totalProgress)} / ${NumberFormatters.fiat(target)} ${state.fiat} (${"%.0f".format(pct * 100)} %)" ) } } @@ -752,7 +753,7 @@ private fun LadderPreviewTable( val profitPctText = if (avg != null && avg > BigDecimal.ZERO) { val pct = (o.limitPrice - avg).divide(avg, 4, RoundingMode.HALF_UP) .multiply(BigDecimal(100)).setScale(1, RoundingMode.HALF_UP) - "${if (pct.signum() >= 0) "+" else ""}${pct.toPlainString()}%" + "${if (pct.signum() >= 0) "+" else ""}${pct.toPlainString()} %" } else "-" totalNet += if (avg != null) net - o.cryptoAmount * avg else net Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 9617e95..5464fb6 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -981,8 +981,9 @@ Aktuální cena: Průměrný nákup: K dispozici: - Množství + Množství k prodeji Vše + Bez ztráty Limitní cena Tržní Souhrn diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index d667652..b5dd9e2 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -975,8 +975,9 @@ Current price: Avg buy price: Available: - Amount + Amount to sell All + Breakeven Limit price Market Summary From 600f2ca26623532eac6cf3b0a699d94bc152c05d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 13:40:39 +0200 Subject: [PATCH 51/75] fix(sell): single-line chip labels, drop Breakeven chip Adds maxLines=1 + softWrap=false to all preset chip Text composables so '+100 %' / 'Vse' / etc. don't wrap. Removes the Breakeven chip from the price row - the +X % chips already cover that intent and the button was redundant. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../screens/plans/sell/SellWizardBottomSheet.kt | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index c256d8c..369a7ee 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -240,7 +240,7 @@ private fun SellInputStep( listOf(25 to "25 %", 50 to "50 %", 75 to "75 %", 100 to allLabel).forEach { (pct, label) -> AssistChip( onClick = { vm.setAmountPct(pct) }, - label = { Text(label) } + label = { Text(label, maxLines = 1, softWrap = false) } ) } } @@ -287,27 +287,22 @@ private fun SellInputStep( ) { AssistChip( onClick = vm::setPriceSpot, - label = { Text(stringResource(R.string.sell_wizard_chip_spot)) }, + label = { Text(stringResource(R.string.sell_wizard_chip_spot), maxLines = 1, softWrap = false) }, enabled = state.spotPrice != null ) - AssistChip( - onClick = vm::setPriceBreakeven, - label = { Text(stringResource(R.string.sell_wizard_chip_breakeven)) }, - enabled = state.avgBuyPrice != null - ) AssistChip( onClick = { vm.setPriceAvgPlus(10) }, - label = { Text("+10 %") }, + label = { Text("+10 %", maxLines = 1, softWrap = false) }, enabled = state.avgBuyPrice != null ) AssistChip( onClick = { vm.setPriceAvgPlus(25) }, - label = { Text("+25 %") }, + label = { Text("+25 %", maxLines = 1, softWrap = false) }, enabled = state.avgBuyPrice != null ) AssistChip( onClick = { vm.setPriceAvgPlus(50) }, - label = { Text("+50 %") }, + label = { Text("+50 %", maxLines = 1, softWrap = false) }, enabled = state.avgBuyPrice != null ) } @@ -347,7 +342,7 @@ private fun SellInputStep( listOf(0.10 to "+10 %", 0.20 to "+20 %", 0.50 to "+50 %", 1.00 to "+100 %").forEach { (factor, label) -> AssistChip( onClick = { vm.applyNetProfitPreset(factor) }, - label = { Text(label) }, + label = { Text(label, maxLines = 1, softWrap = false) }, enabled = state.avgBuyPrice != null && state.amountInput.toBigDecimalOrNull() != null ) } From a6acca316703bdc36c422f84016a781d37f5c6ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 13:53:46 +0200 Subject: [PATCH 52/75] fix(sell): full-screen wizard, clickable label, thousand separators - Replace ModalBottomSheet with Dialog (usePlatformDefaultWidth=false, dismissOnClickOutside=false) so the wizard fills the screen and cannot be dismissed accidentally by swipe. - Ladder checkbox row is fully clickable (toggles via Modifier.clickable). - Fiat number fields (avg buy, limit price, net proceeds, ladder from/to in price mode) get a ThousandSeparator VisualTransformation that inserts a space every 3 digits in the integer part. Cursor mapping preserves typing position. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/sell/SellWizardBottomSheet.kt | 97 ++++++++++++++++--- 1 file changed, 84 insertions(+), 13 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index 369a7ee..90c7b59 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -1,14 +1,18 @@ package com.accbot.dca.presentation.screens.plans.sell +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -22,18 +26,17 @@ import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -42,8 +45,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.accbot.dca.R @@ -76,17 +85,24 @@ fun SellWizardBottomSheet( } } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - - ModalBottomSheet( + Dialog( onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface, - modifier = Modifier.fillMaxHeight(0.95f) + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false + ) ) { - when (state.step) { - SellWizardViewModel.Step.INPUT -> SellInputStep(state, viewModel, onDismiss) - SellWizardViewModel.Step.CONFIRM -> SellConfirmStep(state, viewModel) + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Box(modifier = Modifier.statusBarsPadding()) { + when (state.step) { + SellWizardViewModel.Step.INPUT -> SellInputStep(state, viewModel, onDismiss) + SellWizardViewModel.Step.CONFIRM -> SellConfirmStep(state, viewModel) + } + } } } @@ -177,6 +193,7 @@ private fun SellInputStep( OutlinedTextField( value = state.avgBuyPriceInput, onValueChange = vm::setAvgBuyPrice, + visualTransformation = ThousandSeparator, trailingIcon = { Row(verticalAlignment = Alignment.CenterVertically) { if (state.avgBuyPriceManual && state.avgBuyPriceAuto != null) { @@ -247,8 +264,13 @@ private fun SellInputStep( // Ladder mode toggle Spacer(Modifier.height(12.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - androidx.compose.material3.Checkbox( + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { vm.setLadderEnabled(!state.ladderEnabled) } + ) { + Checkbox( checked = state.ladderEnabled, onCheckedChange = vm::setLadderEnabled ) @@ -270,6 +292,7 @@ private fun SellInputStep( OutlinedTextField( value = state.priceInput, onValueChange = vm::setPrice, + visualTransformation = ThousandSeparator, trailingIcon = { Text( text = state.fiat, @@ -318,6 +341,7 @@ private fun SellInputStep( OutlinedTextField( value = state.netInput, onValueChange = vm::setNetFiat, + visualTransformation = ThousandSeparator, trailingIcon = { Text( text = state.fiat, @@ -660,11 +684,14 @@ private fun LadderControls(state: SellWizardViewModel.UiState, vm: SellWizardVie // From / To Spacer(Modifier.height(8.dp)) + val rangeTransform = if (state.ladderRangeMode == SellWizardViewModel.LadderRangeMode.PRICE) + ThousandSeparator else VisualTransformation.None Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedTextField( value = state.ladderFromInput, onValueChange = vm::setLadderFrom, label = { Text(stringResource(R.string.sell_wizard_ladder_from)) }, + visualTransformation = rangeTransform, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), modifier = Modifier.weight(1f), singleLine = true @@ -673,6 +700,7 @@ private fun LadderControls(state: SellWizardViewModel.UiState, vm: SellWizardVie value = state.ladderToInput, onValueChange = vm::setLadderTo, label = { Text(stringResource(R.string.sell_wizard_ladder_to)) }, + visualTransformation = rangeTransform, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), modifier = Modifier.weight(1f), singleLine = true @@ -769,6 +797,49 @@ private fun LadderPreviewTable( } } +/** + * Inserts a thin space (' ') as a thousand separator in the integer part of a numeric + * input. The decimal part (after '.' or ',') is left as-is. The user keeps typing raw + * digits; only the visual presentation is grouped. + */ +private val ThousandSeparator: VisualTransformation = VisualTransformation { text -> + val raw = text.text + if (raw.isEmpty()) return@VisualTransformation TransformedText(text, OffsetMapping.Identity) + val transformed = formatThousands(raw) + TransformedText( + AnnotatedString(transformed), + object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + if (offset <= 0) return 0 + val capped = offset.coerceAtMost(raw.length) + return formatThousands(raw.substring(0, capped)).length + } + + override fun transformedToOriginal(offset: Int): Int { + if (offset <= 0) return 0 + if (offset >= transformed.length) return raw.length + var seen = 0 + var orig = 0 + for (ch in transformed) { + if (seen >= offset) break + seen++ + if (ch != ' ') orig++ + } + return orig.coerceAtMost(raw.length) + } + } + ) +} + +private fun formatThousands(s: String): String { + val dotIdx = s.indexOfAny(charArrayOf('.', ',')) + val intPart = if (dotIdx >= 0) s.substring(0, dotIdx) else s + val rest = if (dotIdx >= 0) s.substring(dotIdx) else "" + val grouped = if (intPart.length <= 3) intPart + else intPart.reversed().chunked(3).joinToString(" ").reversed() + return grouped + rest +} + @Composable internal fun LossBanner(text: String) { Surface( From ef4b2a9cf70c9ff9ef3183931b65637ed0c55d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 13:59:53 +0200 Subject: [PATCH 53/75] fix(sell): hide summary when proceeds=0 or in ladder mode Empty inputs gave proceeds=0 and the summary rendered 'Ziskate: 0 CZK' which is noise. Now the whole Souhrn section shows only when proceeds > 0 in single mode. In ladder mode the preview table is the summary, so the single-mode summary block is hidden entirely (loss banner too). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/sell/SellWizardBottomSheet.kt | 108 +++++++++--------- 1 file changed, 56 insertions(+), 52 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index 90c7b59..de1688a 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -373,72 +373,76 @@ private fun SellInputStep( } } // end if !ladderEnabled - Spacer(Modifier.height(16.dp)) - Text( - stringResource(R.string.sell_wizard_summary), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold - ) - Spacer(Modifier.height(4.dp)) val amountBD = state.amountInput.toBigDecimalOrNull() ?: BigDecimal.ZERO val priceBD = state.priceInput.toBigDecimalOrNull() ?: BigDecimal.ZERO val proceeds = (amountBD * priceBD).setScale(2, RoundingMode.HALF_UP) val feeAmount = (proceeds * state.feeRate).setScale(2, RoundingMode.HALF_UP) val netProceeds = (proceeds - feeAmount).setScale(2, RoundingMode.HALF_UP) - InfoRow( - stringResource(R.string.sell_wizard_proceeds), - "${NumberFormatters.fiat(proceeds)} ${state.fiat}" - ) - if (state.feeRate > BigDecimal.ZERO && proceeds > BigDecimal.ZERO) { - InfoRow( - stringResource(R.string.sell_wizard_summary_fee), - "-${NumberFormatters.fiat(feeAmount)} ${state.fiat} (${state.feeRate.multiply(BigDecimal(100)).setScale(2, RoundingMode.HALF_UP).toPlainString()} %)" + + if (!state.ladderEnabled && proceeds > BigDecimal.ZERO) { + Spacer(Modifier.height(16.dp)) + Text( + stringResource(R.string.sell_wizard_summary), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold ) - } - state.avgBuyPrice?.let { avg -> - val costBasis = amountBD * avg - val netProfit = (netProceeds - costBasis).setScale(2, RoundingMode.HALF_UP) - val netProfitPct = if (costBasis > BigDecimal.ZERO) { - netProfit.divide(costBasis, 4, RoundingMode.HALF_UP) - .multiply(BigDecimal(100)) - .setScale(1, RoundingMode.HALF_UP) - } else BigDecimal.ZERO - val sign = if (netProfit >= BigDecimal.ZERO) "+" else "" + Spacer(Modifier.height(4.dp)) InfoRow( - label = stringResource(R.string.sell_wizard_summary_net_profit), - value = "$sign${NumberFormatters.fiat(netProfit)} ${state.fiat} ($sign${netProfitPct.toPlainString()} %)", - color = when { - netProfit > BigDecimal.ZERO -> successColor() - netProfit < BigDecimal.ZERO -> Error - else -> null - } + stringResource(R.string.sell_wizard_proceeds), + "${NumberFormatters.fiat(proceeds)} ${state.fiat}" ) - - // Target progress - state.targetProfitAmount?.takeIf { it > BigDecimal.ZERO }?.let { target -> - val totalProgress = state.realizedPnLSoFar + netProfit - val pct = (totalProgress.toDouble() / target.toDouble()).coerceAtLeast(0.0) + if (state.feeRate > BigDecimal.ZERO) { + InfoRow( + stringResource(R.string.sell_wizard_summary_fee), + "-${NumberFormatters.fiat(feeAmount)} ${state.fiat} (${state.feeRate.multiply(BigDecimal(100)).setScale(2, RoundingMode.HALF_UP).toPlainString()} %)" + ) + } + state.avgBuyPrice?.let { avg -> + val costBasis = amountBD * avg + val netProfit = (netProceeds - costBasis).setScale(2, RoundingMode.HALF_UP) + val netProfitPct = if (costBasis > BigDecimal.ZERO) { + netProfit.divide(costBasis, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + .setScale(1, RoundingMode.HALF_UP) + } else BigDecimal.ZERO + val sign = if (netProfit >= BigDecimal.ZERO) "+" else "" InfoRow( - stringResource(R.string.sell_wizard_summary_target_progress), - "${NumberFormatters.fiat(totalProgress)} / ${NumberFormatters.fiat(target)} ${state.fiat} (${"%.0f".format(pct * 100)} %)" + label = stringResource(R.string.sell_wizard_summary_net_profit), + value = "$sign${NumberFormatters.fiat(netProfit)} ${state.fiat} ($sign${netProfitPct.toPlainString()} %)", + color = when { + netProfit > BigDecimal.ZERO -> successColor() + netProfit < BigDecimal.ZERO -> Error + else -> null + } ) + + state.targetProfitAmount?.takeIf { it > BigDecimal.ZERO }?.let { target -> + val totalProgress = state.realizedPnLSoFar + netProfit + val pct = (totalProgress.toDouble() / target.toDouble()).coerceAtLeast(0.0) + InfoRow( + stringResource(R.string.sell_wizard_summary_target_progress), + "${NumberFormatters.fiat(totalProgress)} / ${NumberFormatters.fiat(target)} ${state.fiat} (${"%.0f".format(pct * 100)} %)" + ) + } } } - // Loss banner from validations - state.validations.filterIsInstance().firstOrNull()?.let { loss -> - Spacer(Modifier.height(8.dp)) - val isPriceBelowAvg = state.priceInput.toBigDecimalOrNull()?.let { p -> - state.avgBuyPrice?.let { avg -> p < avg } - } ?: false - LossBanner( - stringResource( - if (isPriceBelowAvg) R.string.sell_wizard_loss_below_buy - else R.string.sell_wizard_loss_after_fee, - NumberFormatters.fiat(loss.lossFiat), - state.fiat + // Loss banner from validations (single mode only - ladder has aggregate via preview) + if (!state.ladderEnabled) { + state.validations.filterIsInstance().firstOrNull()?.let { loss -> + Spacer(Modifier.height(8.dp)) + val isPriceBelowAvg = state.priceInput.toBigDecimalOrNull()?.let { p -> + state.avgBuyPrice?.let { avg -> p < avg } + } ?: false + LossBanner( + stringResource( + if (isPriceBelowAvg) R.string.sell_wizard_loss_below_buy + else R.string.sell_wizard_loss_after_fee, + NumberFormatters.fiat(loss.lossFiat), + state.fiat + ) ) - ) + } } // Validations From 55d79089bfc9a90a8f52b6cac02b39ff6dc0ad92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 14:08:31 +0200 Subject: [PATCH 54/75] fix(theme): define container colors for dark color schemes DarkColorScheme and SandboxDarkColorScheme didn't define primaryContainer / tertiaryContainer / errorContainer, so M3 fell back to its purple/violet defaults. Anywhere those tokens were used (InfoBanner, WarningBanner, AssistChip selected state) rendered with a palette that didn't match AccBot's teal/dark-blue look. New container colors are derived from the existing palette: dark teal for primary, dark amber for tertiary/warnings, dark red for errors. Sandbox dark theme reuses the amber tones since its primary is already orange. Light schemes already had container colors so they're unchanged. Sell wizard ladder chips revert to using primaryContainer directly now that it's themed correctly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/sell/SellWizardBottomSheet.kt | 6 +++-- .../accbot/dca/presentation/ui/theme/Theme.kt | 24 +++++++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index de1688a..22ce3de 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -679,7 +679,8 @@ private fun LadderControls(state: SellWizardViewModel.UiState, vm: SellWizardVie label = { Text(label) }, colors = if (state.ladderRangeMode == mode) { androidx.compose.material3.AssistChipDefaults.assistChipColors( - containerColor = MaterialTheme.colorScheme.primaryContainer + containerColor = MaterialTheme.colorScheme.primaryContainer, + labelColor = MaterialTheme.colorScheme.onPrimaryContainer ) } else androidx.compose.material3.AssistChipDefaults.assistChipColors() ) @@ -734,7 +735,8 @@ private fun LadderControls(state: SellWizardViewModel.UiState, vm: SellWizardVie label = { Text(label) }, colors = if (state.ladderAmountMode == mode) { androidx.compose.material3.AssistChipDefaults.assistChipColors( - containerColor = MaterialTheme.colorScheme.primaryContainer + containerColor = MaterialTheme.colorScheme.primaryContainer, + labelColor = MaterialTheme.colorScheme.onPrimaryContainer ) } else androidx.compose.material3.AssistChipDefaults.assistChipColors() ) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/ui/theme/Theme.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/ui/theme/Theme.kt index fd2b45c..b45b243 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/ui/theme/Theme.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/ui/theme/Theme.kt @@ -41,8 +41,16 @@ val SandboxSuccess = Color(0xFFFFA726) private val DarkColorScheme = darkColorScheme( primary = Primary, onPrimary = OnPrimary, + primaryContainer = Color(0xFF1F4E40), // dark teal - matches AccBot palette + onPrimaryContainer = Color(0xFFA8E5CD), secondary = Secondary, onSecondary = OnSecondary, + secondaryContainer = Color(0xFF142B4D), // slightly darker variant of Secondary + onSecondaryContainer = Color(0xFFB0CCEE), + tertiary = Warning, + onTertiary = Color(0xFF1A1A2E), + tertiaryContainer = Color(0xFF553300), // dark amber for warning surfaces + onTertiaryContainer = Color(0xFFFFD194), background = Background, onBackground = OnBackground, surface = Surface, @@ -50,7 +58,9 @@ private val DarkColorScheme = darkColorScheme( surfaceVariant = SurfaceVariant, onSurfaceVariant = OnSurfaceVariant, error = Error, - onError = Color.White + onError = Color.White, + errorContainer = Color(0xFF601824), + onErrorContainer = Color(0xFFFFB4B4) ) private val LightColorScheme = lightColorScheme( @@ -84,8 +94,16 @@ private val LightColorScheme = lightColorScheme( private val SandboxDarkColorScheme = darkColorScheme( primary = SandboxPrimary, onPrimary = OnPrimary, + primaryContainer = Color(0xFF553300), // dark amber for sandbox primary + onPrimaryContainer = Color(0xFFFFD194), secondary = Secondary, onSecondary = OnSecondary, + secondaryContainer = Color(0xFF142B4D), + onSecondaryContainer = Color(0xFFB0CCEE), + tertiary = Warning, + onTertiary = Color(0xFF1A1A2E), + tertiaryContainer = Color(0xFF553300), + onTertiaryContainer = Color(0xFFFFD194), background = Background, onBackground = OnBackground, surface = Surface, @@ -93,7 +111,9 @@ private val SandboxDarkColorScheme = darkColorScheme( surfaceVariant = SurfaceVariant, onSurfaceVariant = OnSurfaceVariant, error = Error, - onError = Color.White + onError = Color.White, + errorContainer = Color(0xFF601824), + onErrorContainer = Color(0xFFFFB4B4) ) // Sandbox light color scheme (orange theme) From 80a80a670680156164ca4af910f119ac2f5a326e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 14:17:08 +0200 Subject: [PATCH 55/75] fix(sell): diacritics + per-field validation errors Adds Czech diacritics back to all validation messages ('Mnozstvi' -> 'Mnozstvi musi byt' -> 'Mnozstvi musi byt') and tags each HardError with the input field it relates to (AMOUNT / PRICE / NET / GENERIC). The wizard renders field-tagged errors as supportingText (with red border) directly under the matching OutlinedTextField, keeping only GENERIC errors in the bottom list. Same diacritic fix in the ladder hard-error strings on the VM side. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../usecase/ValidateSellOrderUseCase.kt | 14 ++++--- .../plans/sell/SellWizardBottomSheet.kt | 37 +++++++++++++++---- .../screens/plans/sell/SellWizardViewModel.kt | 14 +++---- 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt index b6d9bef..cdd9835 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt @@ -12,8 +12,11 @@ import javax.inject.Inject * [Ok] is only emitted when the list would otherwise be empty. */ sealed class SellValidation { + /** Field this validation result attaches to (lets UI render it under the right input). */ + enum class Field { AMOUNT, PRICE, NET, GENERIC } + object Ok : SellValidation() - data class HardError(val message: String) : SellValidation() + data class HardError(val message: String, val field: Field = Field.GENERIC) : SellValidation() data class InstantFillInfo(val spot: BigDecimal) : SellValidation() data class FarFromMarketWarning(val spot: BigDecimal) : SellValidation() /** @@ -51,15 +54,15 @@ class ValidateSellOrderUseCase @Inject constructor( val result = mutableListOf() if (cryptoAmount <= BigDecimal.ZERO) { - result += SellValidation.HardError("Mnozstvi musi byt vetsi nez 0") + result += SellValidation.HardError("Množství musí být větší než 0", SellValidation.Field.AMOUNT) return result } if (limitPrice <= BigDecimal.ZERO) { - result += SellValidation.HardError("Limitni cena musi byt vetsi nez 0") + result += SellValidation.HardError("Limitní cena musí být větší než 0", SellValidation.Field.PRICE) return result } if (cryptoAmount < minOrderSize) { - result += SellValidation.HardError("Minimalni order je $minOrderSize") + result += SellValidation.HardError("Minimální order je $minOrderSize", SellValidation.Field.AMOUNT) } val tx = database.transactionDao().getTransactionsByPlanSync(planId) @@ -87,7 +90,8 @@ class ValidateSellOrderUseCase @Inject constructor( val available = held - openSellsRequested if (cryptoAmount > available) { result += SellValidation.HardError( - "Nemas tolik k dispozici (k dispozici $available)" + "Nemáš tolik k dispozici (k dispozici $available)", + SellValidation.Field.AMOUNT ) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index 22ce3de..5bc99c0 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -228,6 +228,14 @@ private fun SellInputStep( singleLine = true ) + // Field-specific errors: shown as supportingText under the matching field. + val amountError = state.validations.filterIsInstance() + .firstOrNull { it.field == SellValidation.Field.AMOUNT } + val priceError = state.validations.filterIsInstance() + .firstOrNull { it.field == SellValidation.Field.PRICE } + val netError = state.validations.filterIsInstance() + .firstOrNull { it.field == SellValidation.Field.NET } + Spacer(Modifier.height(16.dp)) Text( stringResource(R.string.sell_wizard_amount), @@ -238,6 +246,10 @@ private fun SellInputStep( OutlinedTextField( value = state.amountInput, onValueChange = vm::setAmount, + isError = amountError != null, + supportingText = amountError?.let { + { Text(it.message, color = MaterialTheme.colorScheme.error) } + }, trailingIcon = { Text( text = state.crypto, @@ -293,6 +305,10 @@ private fun SellInputStep( value = state.priceInput, onValueChange = vm::setPrice, visualTransformation = ThousandSeparator, + isError = priceError != null, + supportingText = priceError?.let { + { Text(it.message, color = MaterialTheme.colorScheme.error) } + }, trailingIcon = { Text( text = state.fiat, @@ -342,6 +358,10 @@ private fun SellInputStep( value = state.netInput, onValueChange = vm::setNetFiat, visualTransformation = ThousandSeparator, + isError = netError != null, + supportingText = netError?.let { + { Text(it.message, color = MaterialTheme.colorScheme.error) } + }, trailingIcon = { Text( text = state.fiat, @@ -445,16 +465,19 @@ private fun SellInputStep( } } - // Validations + // Validations: field-tagged hard errors render under their field; only generic + // ones and warnings end up in this list. Spacer(Modifier.height(8.dp)) state.validations.forEach { v -> when (v) { - is SellValidation.HardError -> Text( - text = v.message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(vertical = 4.dp) - ) + is SellValidation.HardError -> if (v.field == SellValidation.Field.GENERIC) { + Text( + text = v.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(vertical = 4.dp) + ) + } is SellValidation.InstantFillInfo -> InfoBanner( stringResource(R.string.sell_wizard_instant_fill_warning, NumberFormatters.fiat(v.spot), state.fiat) ) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt index b284d78..736bf7a 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt @@ -355,7 +355,7 @@ class SellWizardViewModel @Inject constructor( LadderRangeMode.PROFIT_PCT -> { if (avg == null) { _uiState.update { - it.copy(ladderPreview = emptyList(), ladderHardError = "Pro profit % musi byt avg buy price") + it.copy(ladderPreview = emptyList(), ladderHardError = "Pro profit % musí být zadaná průměrná nákupní cena") } return } @@ -372,26 +372,26 @@ class SellWizardViewModel @Inject constructor( return } if (total <= BigDecimal.ZERO) { - _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = "Mnozstvi > 0") } + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = "Množství musí být větší než 0") } return } if (count < 2) { - _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = "Pocet >= 2") } + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = "Počet orderů musí být alespoň 2") } return } if (to <= from || from <= BigDecimal.ZERO) { - _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = "Do > Od, oba > 0") } + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = "Do musí být větší než Od (oba kladné)") } return } if (total > st.availableToSell) { - _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = "Nemas tolik k dispozici") } + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = "Nemáš tolik k dispozici") } return } val preview = LadderGenerator.generate(total, from, to, count, st.ladderAmountMode) val minPerOrder = preview.minOf { it.cryptoAmount } val hardError = if (minPerOrder < st.minOrderSize) { - "Order ${minPerOrder} < min ${st.minOrderSize}" + "Velikost orderu ${minPerOrder} je pod minimem ${st.minOrderSize}" } else null _uiState.update { it.copy(ladderPreview = preview, ladderHardError = hardError) } @@ -468,7 +468,7 @@ class SellWizardViewModel @Inject constructor( _uiState.update { it.copy( submitting = false, - submitError = result.exceptionOrNull()?.message ?: "Neznama chyba" + submitError = result.exceptionOrNull()?.message ?: "Neznámá chyba" ) } } From 2cf2960f285b1eca92bf054cc9b0b093311a2df8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 14:56:38 +0200 Subject: [PATCH 56/75] fix(sell): green shade tweak, ladder error placement, IME padding - Tweak primaryContainer to a mint that shares Primary's exact hue (Primary at ~50% brightness) instead of the forest-green it was. - Ladder Od/Do now show isError=true and the validation message renders directly below the row instead of two sections lower. - imePadding() on the wizard scrollable Column so the keyboard pushes content up; the user can now scroll to fields below the keyboard. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/sell/SellWizardBottomSheet.kt | 19 ++++++++++++++----- .../accbot/dca/presentation/ui/theme/Theme.kt | 4 ++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index 5bc99c0..0117dd1 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding @@ -138,6 +139,7 @@ private fun SellInputStep( Column( modifier = Modifier .fillMaxWidth() + .imePadding() .padding(horizontal = 16.dp) .verticalScroll(rememberScrollState()) ) { @@ -514,6 +516,7 @@ private fun SellConfirmStep( Column( modifier = Modifier .fillMaxWidth() + .imePadding() .padding(horizontal = 16.dp) .verticalScroll(rememberScrollState()) ) { @@ -714,12 +717,14 @@ private fun LadderControls(state: SellWizardViewModel.UiState, vm: SellWizardVie Spacer(Modifier.height(8.dp)) val rangeTransform = if (state.ladderRangeMode == SellWizardViewModel.LadderRangeMode.PRICE) ThousandSeparator else VisualTransformation.None + val rangeError = state.ladderHardError != null Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedTextField( value = state.ladderFromInput, onValueChange = vm::setLadderFrom, label = { Text(stringResource(R.string.sell_wizard_ladder_from)) }, visualTransformation = rangeTransform, + isError = rangeError, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), modifier = Modifier.weight(1f), singleLine = true @@ -729,11 +734,20 @@ private fun LadderControls(state: SellWizardViewModel.UiState, vm: SellWizardVie onValueChange = vm::setLadderTo, label = { Text(stringResource(R.string.sell_wizard_ladder_to)) }, visualTransformation = rangeTransform, + isError = rangeError, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), modifier = Modifier.weight(1f), singleLine = true ) } + state.ladderHardError?.let { err -> + Text( + err, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp, start = 16.dp) + ) + } // Count Spacer(Modifier.height(8.dp)) @@ -766,11 +780,6 @@ private fun LadderControls(state: SellWizardViewModel.UiState, vm: SellWizardVie } } - state.ladderHardError?.let { err -> - Spacer(Modifier.height(8.dp)) - Text(err, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) - } - if (state.ladderPreview.isNotEmpty()) { Spacer(Modifier.height(12.dp)) LadderPreviewTable( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/ui/theme/Theme.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/ui/theme/Theme.kt index b45b243..57e0f0e 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/ui/theme/Theme.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/ui/theme/Theme.kt @@ -41,8 +41,8 @@ val SandboxSuccess = Color(0xFFFFA726) private val DarkColorScheme = darkColorScheme( primary = Primary, onPrimary = OnPrimary, - primaryContainer = Color(0xFF1F4E40), // dark teal - matches AccBot palette - onPrimaryContainer = Color(0xFFA8E5CD), + primaryContainer = Color(0xFF276652), // Primary @ ~50% brightness, same mint hue + onPrimaryContainer = Color(0xFFB8E8D5), secondary = Secondary, onSecondary = OnSecondary, secondaryContainer = Color(0xFF142B4D), // slightly darker variant of Secondary From feb8a965b057bd50ca3c4fe1422e5af84713b2c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 15:04:33 +0200 Subject: [PATCH 57/75] fix(sell): fiat-based min order check via MinOrderSizeRepository Coinmate's minimum is denominated in fiat (50 CZK / 2 EUR), not crypto. Previously the wizard hardcoded 0.00001 BTC as the min for non-Binance exchanges, which let users submit sub-50 CZK orders that the exchange then rejected. Now SellWizardViewModel injects MinOrderSizeRepository and fetches the fiat min in init(). ValidateSellOrderUseCase compares 'cryptoAmount * limitPrice' against minOrderFiat instead of 'cryptoAmount < minCrypto'. Ladder validation does the same per smallest-order. Validation message now reads 'Minimalni hodnota orderu je 50 CZK (zvys mnozstvi nebo cenu)'. Same change benefits Binance (NOTIONAL filter) - the repo already fetches minNotional from /exchangeInfo. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../usecase/ValidateSellOrderUseCase.kt | 10 ++++--- .../screens/plans/sell/SellWizardViewModel.kt | 26 +++++++++++-------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt index cdd9835..04caf65 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt @@ -46,7 +46,8 @@ class ValidateSellOrderUseCase @Inject constructor( planId: Long, cryptoAmount: BigDecimal, limitPrice: BigDecimal, - minOrderSize: BigDecimal, + /** Minimum order size **in fiat** (Coinmate ~50 CZK, Binance NOTIONAL filter, etc.). */ + minOrderFiat: BigDecimal, currentSpot: BigDecimal?, avgBuyPrice: BigDecimal? = null, feeRate: BigDecimal = BigDecimal.ZERO @@ -61,8 +62,11 @@ class ValidateSellOrderUseCase @Inject constructor( result += SellValidation.HardError("Limitní cena musí být větší než 0", SellValidation.Field.PRICE) return result } - if (cryptoAmount < minOrderSize) { - result += SellValidation.HardError("Minimální order je $minOrderSize", SellValidation.Field.AMOUNT) + if (minOrderFiat > BigDecimal.ZERO && cryptoAmount * limitPrice < minOrderFiat) { + result += SellValidation.HardError( + "Minimální hodnota orderu je $minOrderFiat (zvyš množství nebo cenu)", + SellValidation.Field.AMOUNT + ) } val tx = database.transactionDao().getTransactionsByPlanSync(planId) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt index 736bf7a..a1bcb70 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt @@ -19,6 +19,7 @@ import com.accbot.dca.domain.usecase.PlaceLimitSellUseCase import com.accbot.dca.domain.usecase.SellValidation import com.accbot.dca.domain.usecase.ValidateSellOrderUseCase import com.accbot.dca.exchange.ExchangeApiFactory +import com.accbot.dca.exchange.MinOrderSizeRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -51,7 +52,8 @@ class SellWizardViewModel @Inject constructor( private val database: DcaDatabase, private val credentialsStore: CredentialsStore, private val exchangeApiFactory: ExchangeApiFactory, - private val userPreferences: UserPreferences + private val userPreferences: UserPreferences, + private val minOrderSizeRepository: MinOrderSizeRepository ) : ViewModel() { enum class Step { INPUT, CONFIRM } @@ -75,7 +77,8 @@ class SellWizardViewModel @Inject constructor( val avgBuyPriceInput: String = "", /** True when [avgBuyPriceInput] differs from auto - drives the reset chip. */ val avgBuyPriceManual: Boolean = false, - val minOrderSize: BigDecimal = BigDecimal("0.00001"), + /** Minimum order size **in fiat** (e.g. 50 CZK on Coinmate). */ + val minOrderFiat: BigDecimal = BigDecimal.ZERO, val amountInput: String = "", val priceInput: String = "", val netInput: String = "", @@ -165,10 +168,11 @@ class SellWizardViewModel @Inject constructor( val held = pnl?.currentCryptoHeld ?: inventory.available val realizedSoFar = pnl?.realizedPnL ?: BigDecimal.ZERO - val minOrder = when (plan.exchange) { - Exchange.BINANCE -> - Exchange.binanceLotStepSize[plan.crypto]?.let(::BigDecimal) ?: BigDecimal("0.00001") - else -> BigDecimal("0.00001") + val minOrderFiat = try { + minOrderSizeRepository.getMinOrderSize(plan.exchange, plan.crypto, plan.fiat) + } catch (e: Exception) { + Log.w(TAG, "min order fetch failed: ${e.message}") + BigDecimal.ZERO } _uiState.update { @@ -184,7 +188,7 @@ class SellWizardViewModel @Inject constructor( avgBuyPriceAuto = inventory.weightedAvgPrice, avgBuyPriceInput = inventory.weightedAvgPrice?.setScale(2, RoundingMode.HALF_UP)?.toPlainString() ?: "", avgBuyPriceManual = false, - minOrderSize = minOrder, + minOrderFiat = minOrderFiat, feeRate = feeRate, targetProfitAmount = plan.targetProfitAmount, realizedPnLSoFar = realizedSoFar, @@ -389,9 +393,9 @@ class SellWizardViewModel @Inject constructor( } val preview = LadderGenerator.generate(total, from, to, count, st.ladderAmountMode) - val minPerOrder = preview.minOf { it.cryptoAmount } - val hardError = if (minPerOrder < st.minOrderSize) { - "Velikost orderu ${minPerOrder} je pod minimem ${st.minOrderSize}" + val minOrderFiatValue = preview.minOf { it.cryptoAmount * it.limitPrice } + val hardError = if (st.minOrderFiat > BigDecimal.ZERO && minOrderFiatValue < st.minOrderFiat) { + "Hodnota nejmenšího orderu (${minOrderFiatValue.setScale(2, RoundingMode.HALF_UP)} ${st.fiat}) je pod minimem ${st.minOrderFiat} ${st.fiat}" } else null _uiState.update { it.copy(ladderPreview = preview, ladderHardError = hardError) } @@ -494,7 +498,7 @@ class SellWizardViewModel @Inject constructor( viewModelScope.launch { val validations = try { validateSellOrderUseCase( - state.planId, amount, price, state.minOrderSize, state.spotPrice, + state.planId, amount, price, state.minOrderFiat, state.spotPrice, state.avgBuyPrice, state.feeRate ) } catch (e: Exception) { From ab2de2346ebcbf3bc6f38032cab277a0d754d525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 23:15:27 +0200 Subject: [PATCH 58/75] fix(sell): preserve ladder range on toggle, show net field in ladder mode - setLadderRangeMode no longer clears From/To. When toggling between Profit % and Price it converts the values via the avg buy price (price = avg * (1 + pct/100), pct = (price - avg)/avg * 100). If avg is unknown the fields clear (no other option). - Net fiat field is now visible in ladder mode too, but read-only. Value = sum of per-order nets across the preview, so the user has the same '3 fields' visual structure as in single mode and sees the total expected proceeds at a glance. Profit-target chips stay hidden in ladder mode (they only make sense for single orders). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/sell/SellWizardBottomSheet.kt | 55 +++++++++++-------- .../screens/plans/sell/SellWizardViewModel.kt | 25 ++++++++- 2 files changed, 56 insertions(+), 24 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index 0117dd1..e79bd6d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -300,7 +300,7 @@ private fun SellInputStep( Text( stringResource(R.string.sell_wizard_limit_price), style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, ) Spacer(Modifier.height(4.dp)) OutlinedTextField( @@ -348,7 +348,9 @@ private fun SellInputStep( ) } - // Net fiat field (3rd of the calculator triple) + } // end if !ladderEnabled (Limit price section only) + + // Net fiat field - editable in single mode, read-only display of ladder total in ladder mode. Spacer(Modifier.height(16.dp)) Text( stringResource(R.string.sell_wizard_net_fiat), @@ -356,12 +358,18 @@ private fun SellInputStep( fontWeight = FontWeight.SemiBold ) Spacer(Modifier.height(4.dp)) + val ladderTotalNetText = if (state.ladderEnabled && state.ladderPreview.isNotEmpty()) { + state.ladderPreview.fold(BigDecimal.ZERO) { acc, o -> + acc + (o.cryptoAmount * o.limitPrice * (BigDecimal.ONE - state.feeRate)) + }.setScale(2, RoundingMode.HALF_UP).toPlainString() + } else null OutlinedTextField( - value = state.netInput, - onValueChange = vm::setNetFiat, + value = ladderTotalNetText ?: state.netInput, + onValueChange = if (state.ladderEnabled) { _ -> } else vm::setNetFiat, + readOnly = state.ladderEnabled, visualTransformation = ThousandSeparator, - isError = netError != null, - supportingText = netError?.let { + isError = !state.ladderEnabled && netError != null, + supportingText = netError?.takeIf { !state.ladderEnabled }?.let { { Text(it.message, color = MaterialTheme.colorScheme.error) } }, trailingIcon = { @@ -375,25 +383,26 @@ private fun SellInputStep( modifier = Modifier.fillMaxWidth(), singleLine = true ) - Text( - stringResource(R.string.sell_wizard_net_preset_label), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp) - ) - Row( - modifier = Modifier.padding(top = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - listOf(0.10 to "+10 %", 0.20 to "+20 %", 0.50 to "+50 %", 1.00 to "+100 %").forEach { (factor, label) -> - AssistChip( - onClick = { vm.applyNetProfitPreset(factor) }, - label = { Text(label, maxLines = 1, softWrap = false) }, - enabled = state.avgBuyPrice != null && state.amountInput.toBigDecimalOrNull() != null - ) + if (!state.ladderEnabled) { + Text( + stringResource(R.string.sell_wizard_net_preset_label), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + Row( + modifier = Modifier.padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + listOf(0.10 to "+10 %", 0.20 to "+20 %", 0.50 to "+50 %", 1.00 to "+100 %").forEach { (factor, label) -> + AssistChip( + onClick = { vm.applyNetProfitPreset(factor) }, + label = { Text(label, maxLines = 1, softWrap = false) }, + enabled = state.avgBuyPrice != null && state.amountInput.toBigDecimalOrNull() != null + ) + } } } - } // end if !ladderEnabled val amountBD = state.amountInput.toBigDecimalOrNull() ?: BigDecimal.ZERO val priceBD = state.priceInput.toBigDecimalOrNull() ?: BigDecimal.ZERO diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt index a1bcb70..d7979dc 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt @@ -319,7 +319,30 @@ class SellWizardViewModel @Inject constructor( } fun setLadderRangeMode(mode: LadderRangeMode) { - _uiState.update { it.copy(ladderRangeMode = mode, ladderFromInput = "", ladderToInput = "") } + _uiState.update { st -> + if (st.ladderRangeMode == mode) return@update st + val avg = st.avgBuyPrice + val convert: (String) -> String = { input -> + val v = input.toBigDecimalOrNull() + if (v == null || avg == null || avg <= BigDecimal.ZERO) "" + else when (mode) { + // PROFIT_PCT -> PRICE: price = avg * (1 + pct/100) + LadderRangeMode.PRICE -> + (avg * (BigDecimal.ONE + v.divide(BigDecimal(100), 8, RoundingMode.HALF_UP))) + .setScale(2, RoundingMode.HALF_UP).toPlainString() + // PRICE -> PROFIT_PCT: pct = (price - avg) / avg * 100 + LadderRangeMode.PROFIT_PCT -> + (v - avg).divide(avg, 6, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + .setScale(2, RoundingMode.HALF_UP).toPlainString() + } + } + st.copy( + ladderRangeMode = mode, + ladderFromInput = convert(st.ladderFromInput), + ladderToInput = convert(st.ladderToInput) + ) + } recomputeLadderPreview() } From f6f649e66755f0db339ac513935f36464de6ff34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 23:39:15 +0200 Subject: [PATCH 59/75] feat(sell): editable ladder net + amount-pct hint - Net field in ladder mode is editable: typing a target net total adjusts the upper bound (To) so the linear-distributed ladder hits that total given current amount + From. Math: total_net = amount * (from+to)/2 * (1-fee) -> to = 2*net/(amount*(1-fee)) - from. Works in both Profit % and Price modes (converts From through avg buy when needed). - recomputeLadderPreview optionally syncs netInput from total. Edits to amount/from/to/count/mode sync net = preview total; edits to net itself skip the sync so the user-typed value stays. - Amount field shows '= X % z dostupnych' as supportingText so the user sees the ratio whether they typed manually or it was computed via the 3-field calculator. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/sell/SellWizardBottomSheet.kt | 24 +++++---- .../screens/plans/sell/SellWizardViewModel.kt | 53 ++++++++++++++++++- 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index e79bd6d..f44f0d4 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -245,12 +245,22 @@ private fun SellInputStep( fontWeight = FontWeight.SemiBold ) Spacer(Modifier.height(4.dp)) + val amountAsBd = state.amountInput.toBigDecimalOrNull() + val amountPct = amountAsBd?.let { a -> + if (state.availableToSell > BigDecimal.ZERO) { + a.divide(state.availableToSell, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)).setScale(1, RoundingMode.HALF_UP) + } else null + } OutlinedTextField( value = state.amountInput, onValueChange = vm::setAmount, isError = amountError != null, - supportingText = amountError?.let { - { Text(it.message, color = MaterialTheme.colorScheme.error) } + supportingText = { + when { + amountError != null -> Text(amountError.message, color = MaterialTheme.colorScheme.error) + amountPct != null -> Text("= ${amountPct.toPlainString()} % z dostupných") + } }, trailingIcon = { Text( @@ -358,15 +368,9 @@ private fun SellInputStep( fontWeight = FontWeight.SemiBold ) Spacer(Modifier.height(4.dp)) - val ladderTotalNetText = if (state.ladderEnabled && state.ladderPreview.isNotEmpty()) { - state.ladderPreview.fold(BigDecimal.ZERO) { acc, o -> - acc + (o.cryptoAmount * o.limitPrice * (BigDecimal.ONE - state.feeRate)) - }.setScale(2, RoundingMode.HALF_UP).toPlainString() - } else null OutlinedTextField( - value = ladderTotalNetText ?: state.netInput, - onValueChange = if (state.ladderEnabled) { _ -> } else vm::setNetFiat, - readOnly = state.ladderEnabled, + value = state.netInput, + onValueChange = if (state.ladderEnabled) vm::setLadderNetTarget else vm::setNetFiat, visualTransformation = ThousandSeparator, isError = !state.ladderEnabled && netError != null, supportingText = netError?.takeIf { !state.ladderEnabled }?.let { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt index d7979dc..dcd901b 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt @@ -366,7 +366,46 @@ class SellWizardViewModel @Inject constructor( recomputeLadderPreview() } - private fun recomputeLadderPreview() { + /** + * Ladder-mode third-field calculator: user enters target net total, we adjust the + * upper bound (`to`) to hit it given the current amount + from. Linear distribution + * with equal-crypto: `total_net = amount * (from+to)/2 * (1-feeRate)`, so + * `to = 2*net/(amount*(1-feeRate)) - from`. + */ + fun setLadderNetTarget(value: String) { + _uiState.update { it.copy(netInput = value) } + val targetNet = value.toBigDecimalOrNull() ?: return + val st = _uiState.value + val amount = st.amountInput.toBigDecimalOrNull() ?: return + if (amount <= BigDecimal.ZERO || targetNet <= BigDecimal.ZERO) return + val factor = BigDecimal.ONE - st.feeRate + if (factor <= BigDecimal.ZERO) return + val avg = st.avgBuyPrice + val fromRaw = st.ladderFromInput.toBigDecimalOrNull() ?: return + val fromPrice = when (st.ladderRangeMode) { + LadderRangeMode.PRICE -> fromRaw + LadderRangeMode.PROFIT_PCT -> { + if (avg == null || avg <= BigDecimal.ZERO) return + avg * (BigDecimal.ONE + fromRaw.divide(BigDecimal(100), 8, RoundingMode.HALF_UP)) + } + } + if (fromPrice <= BigDecimal.ZERO) return + val toPrice = (BigDecimal(2) * targetNet).divide(amount * factor, 8, RoundingMode.HALF_UP) - fromPrice + if (toPrice <= fromPrice) return + val toDisplay = when (st.ladderRangeMode) { + LadderRangeMode.PRICE -> toPrice.setScale(2, RoundingMode.HALF_UP).toPlainString() + LadderRangeMode.PROFIT_PCT -> { + if (avg == null || avg <= BigDecimal.ZERO) return + (toPrice - avg).divide(avg, 6, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + .setScale(2, RoundingMode.HALF_UP).toPlainString() + } + } + _uiState.update { it.copy(ladderToInput = toDisplay) } + recomputeLadderPreview(syncNetFromTotal = false) + } + + private fun recomputeLadderPreview(syncNetFromTotal: Boolean = true) { val st = _uiState.value if (!st.ladderEnabled) { _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = null) } @@ -421,7 +460,17 @@ class SellWizardViewModel @Inject constructor( "Hodnota nejmenšího orderu (${minOrderFiatValue.setScale(2, RoundingMode.HALF_UP)} ${st.fiat}) je pod minimem ${st.minOrderFiat} ${st.fiat}" } else null - _uiState.update { it.copy(ladderPreview = preview, ladderHardError = hardError) } + val totalNet = preview.fold(BigDecimal.ZERO) { acc, o -> + acc + o.cryptoAmount * o.limitPrice * (BigDecimal.ONE - st.feeRate) + }.setScale(2, RoundingMode.HALF_UP) + + _uiState.update { + it.copy( + ladderPreview = preview, + ladderHardError = hardError, + netInput = if (syncNetFromTotal) totalNet.toPlainString() else it.netInput + ) + } } fun submitLadder() { From 22d4c97fb95a0bf6715c17c4325691d38576bf86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 9 May 2026 23:54:51 +0200 Subject: [PATCH 60/75] feat(sell): cancellation status, cancel-all button, cs polish - Add TransactionStatus.CANCELLED for user-cancelled or exchange-side cancelled orders (no fills). FAILED stays for actual rejections. - OpenSellsList: every string is now a stringResource (diacritics in cs), title 'Otevrene prodejni prikazy', cancel dialog/button use proper Czech. Adds 'Zrusit vse' header button (visible when >1 open sell) that confirms then cancels in sequence with aggregate snackbar. - Ladder dialog title 'Vysledek' instead of repeating the checkbox label. Czech ladder strings drop English ('ladder', 'order'): now 'rozdelit na vice prikazu', 'pocet prikazu', 'Zisk %'. - cancelSell error message has diacritics. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/accbot/dca/domain/model/Models.kt | 6 +- .../domain/usecase/CancelSellOrderUseCase.kt | 4 +- .../com/accbot/dca/exchange/BinanceApi.kt | 5 +- .../com/accbot/dca/exchange/CoinbaseApi.kt | 5 +- .../com/accbot/dca/exchange/CoinmateApi.kt | 2 +- .../components/CommonComponents.kt | 1 + .../components/ReusableComponents.kt | 5 ++ .../dca/presentation/screens/HistoryScreen.kt | 1 + .../history/TransactionDetailsScreen.kt | 1 + .../screens/plans/PlanDetailsScreen.kt | 3 +- .../screens/plans/PlanDetailsViewModel.kt | 26 +++++- .../screens/plans/components/OpenSellsList.kt | 82 +++++++++++++++---- .../plans/sell/SellWizardBottomSheet.kt | 2 +- .../app/src/main/res/values-cs/strings.xml | 24 ++++-- .../app/src/main/res/values/strings.xml | 14 +++- 15 files changed, 151 insertions(+), 30 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt index 3299059..def6d76 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt @@ -237,7 +237,11 @@ enum class TransactionStatus { PENDING, COMPLETED, FAILED, - PARTIAL + PARTIAL, + /** User or exchange-side cancellation with no fills. Distinct from FAILED (which + * means the exchange rejected the order outright). PARTIAL covers cancellation + * after some fills happened. */ + CANCELLED } /** diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CancelSellOrderUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CancelSellOrderUseCase.kt index 711aee0..652fb02 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CancelSellOrderUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CancelSellOrderUseCase.kt @@ -13,7 +13,7 @@ import javax.inject.Inject * * On exchange-cancel success locally marks the transaction as: * - PARTIAL if some fills already happened (cryptoAmount > 0) - * - FAILED otherwise + * - CANCELLED otherwise * * On exchange-cancel failure, tries to re-resolve the order status so the UI reflects * the true state (the order may have filled between the user pressing cancel and the @@ -42,7 +42,7 @@ class CancelSellOrderUseCase @Inject constructor( return if (cancelResult.isSuccess) { val newStatus = if (tx.cryptoAmount > BigDecimal.ZERO) - TransactionStatus.PARTIAL else TransactionStatus.FAILED + TransactionStatus.PARTIAL else TransactionStatus.CANCELLED database.transactionDao().updateResolvedTransaction( id = txId, newStatus = newStatus, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt index 612b919..41ed382 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/BinanceApi.kt @@ -360,7 +360,10 @@ class BinanceApi( TransactionStatus.PARTIAL } else TransactionStatus.PENDING "PARTIALLY_FILLED" -> TransactionStatus.PARTIAL - "CANCELED", "CANCELLED", "EXPIRED", "REJECTED", "PENDING_CANCEL" -> + "CANCELED", "CANCELLED", "EXPIRED", "PENDING_CANCEL" -> + if (executedQty > BigDecimal.ZERO) TransactionStatus.PARTIAL + else TransactionStatus.CANCELLED + "REJECTED" -> if (executedQty > BigDecimal.ZERO) TransactionStatus.PARTIAL else TransactionStatus.FAILED else -> return@withContext null diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt index 6d2d4eb..6b0b4a3 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinbaseApi.kt @@ -260,7 +260,10 @@ class CoinbaseApi( TransactionStatus.PARTIAL } else TransactionStatus.PENDING "PARTIALLY_FILLED" -> TransactionStatus.PARTIAL - "CANCELLED", "CANCEL_QUEUED", "EXPIRED", "FAILED", "REJECTED" -> + "CANCELLED", "CANCEL_QUEUED", "EXPIRED" -> + if (filledSize > BigDecimal.ZERO) TransactionStatus.PARTIAL + else TransactionStatus.CANCELLED + "FAILED", "REJECTED" -> if (filledSize > BigDecimal.ZERO) TransactionStatus.PARTIAL else TransactionStatus.FAILED else -> return@withContext null diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt index f7203ba..382abd8 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/CoinmateApi.kt @@ -374,7 +374,7 @@ class CoinmateApi( "PARTIALLY_FILLED" -> TransactionStatus.PARTIAL "CANCELLED", "CANCELED", "EXPIRED" -> if (filledAmount > BigDecimal.ZERO) TransactionStatus.PARTIAL - else TransactionStatus.FAILED + else TransactionStatus.CANCELLED else -> return@withContext null } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/CommonComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/CommonComponents.kt index 442091c..f24d4c4 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/CommonComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/CommonComponents.kt @@ -296,6 +296,7 @@ fun TransactionStatusIcon(status: TransactionStatus) { TransactionStatus.FAILED -> Icons.Default.Error to Error TransactionStatus.PENDING -> Icons.Default.Schedule to accentCol TransactionStatus.PARTIAL -> Icons.Default.RemoveCircle to Color(0xFFFFA500) + TransactionStatus.CANCELLED -> Icons.Default.Cancel to MaterialTheme.colorScheme.onSurfaceVariant } Box( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ReusableComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ReusableComponents.kt index b900ac5..45f2b7e 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ReusableComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ReusableComponents.kt @@ -242,6 +242,11 @@ fun getTransactionStatusStyle(status: TransactionStatus): TransactionStatusStyle color = WarningOrange, label = stringResource(R.string.transaction_status_partial) ) + TransactionStatus.CANCELLED -> TransactionStatusStyle( + icon = Icons.Default.Cancel, + color = MaterialTheme.colorScheme.onSurfaceVariant, + label = stringResource(R.string.transaction_status_cancelled) + ) } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt index e809737..3a3f97d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt @@ -777,6 +777,7 @@ internal fun TransactionCard( TransactionStatus.FAILED -> Icons.Default.Error TransactionStatus.PENDING -> Icons.Default.Schedule TransactionStatus.PARTIAL -> Icons.Default.Warning + TransactionStatus.CANCELLED -> Icons.Default.Cancel }, contentDescription = null, tint = when (transaction.status) { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsScreen.kt index 20c7414..f1ce9de 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/history/TransactionDetailsScreen.kt @@ -344,6 +344,7 @@ private fun StatusHeader(status: TransactionStatus) { TransactionStatus.FAILED -> Triple(Icons.Default.Error, Error, stringResource(R.string.transaction_status_failed)) TransactionStatus.PENDING -> Triple(Icons.Default.Schedule, accentCol, stringResource(R.string.transaction_status_pending)) TransactionStatus.PARTIAL -> Triple(Icons.Default.RemoveCircle, Color(0xFFFFA500), stringResource(R.string.transaction_status_partial)) + TransactionStatus.CANCELLED -> Triple(Icons.Default.Cancel, MaterialTheme.colorScheme.onSurfaceVariant, stringResource(R.string.transaction_status_cancelled)) } Row( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt index c6e4553..aa825f5 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt @@ -771,7 +771,8 @@ fun PlanDetailsScreen( item { OpenSellsList( openSells = openSells, - onCancelClick = viewModel::cancelSell + onCancelClick = viewModel::cancelSell, + onCancelAllClick = viewModel::cancelAllOpenSells ) } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt index 133270a..42d8e0c 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt @@ -278,12 +278,36 @@ class PlanDetailsViewModel @Inject constructor( val result = cancelSellOrderUseCase(txId) if (result.isFailure) { _snackbar.emit( - "Zruseni orderu selhalo: ${result.exceptionOrNull()?.message ?: "neznama chyba"}" + "Zrušení příkazu selhalo: ${result.exceptionOrNull()?.message ?: "neznámá chyba"}" ) } } } + /** + * Cancel every open (PENDING/PARTIAL) sell order on the current plan. Iterates + * sequentially to avoid hammering the exchange. Reports a single aggregate snackbar + * (success count / failure count) once done. + */ + fun cancelAllOpenSells() { + viewModelScope.launch { + val open = _openSells.value + if (open.isEmpty()) return@launch + var ok = 0 + var failed = 0 + for (tx in open) { + val r = cancelSellOrderUseCase(tx.id) + if (r.isSuccess) ok++ else failed++ + } + val msg = when { + failed == 0 -> "Zrušeno $ok příkazů" + ok == 0 -> "Zrušení selhalo u všech ${open.size} příkazů" + else -> "Zrušeno $ok z ${open.size} příkazů ($failed selhalo)" + } + _snackbar.emit(msg) + } + } + private fun fetchFiatBalance(plan: DcaPlan): Job { return viewModelScope.launch { _uiState.update { it.copy(isBalanceLoading = true) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/OpenSellsList.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/OpenSellsList.kt index 4890c14..061c351 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/OpenSellsList.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/components/OpenSellsList.kt @@ -24,10 +24,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.accbot.dca.R import com.accbot.dca.domain.model.Transaction import com.accbot.dca.domain.model.TransactionStatus import com.accbot.dca.presentation.ui.theme.Error @@ -37,16 +39,20 @@ import java.math.RoundingMode /** * Card listing all open (PENDING / PARTIAL) sell orders for a plan with per-row - * cancel action. Hidden entirely when there are no open sells. + * cancel action and a "Cancel all" header button. Hidden entirely when there are no + * open sells. */ @Composable fun OpenSellsList( openSells: List, onCancelClick: (Long) -> Unit, + onCancelAllClick: (() -> Unit)? = null, modifier: Modifier = Modifier ) { if (openSells.isEmpty()) return + var showCancelAllConfirm by remember { mutableStateOf(false) } + Card( modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors( @@ -58,18 +64,55 @@ fun OpenSellsList( .fillMaxWidth() .padding(16.dp) ) { - Text( - text = "Otevrene sell ordery (${openSells.size})", - fontWeight = FontWeight.SemiBold, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.semantics { heading() } - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.open_sells_title, openSells.size), + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.semantics { heading() } + ) + if (onCancelAllClick != null && openSells.size > 1) { + TextButton(onClick = { showCancelAllConfirm = true }) { + Text( + stringResource(R.string.open_sells_cancel_all), + color = Error + ) + } + } + } Spacer(Modifier.height(8.dp)) openSells.forEach { tx -> OpenSellRow(tx = tx, onCancelClick = onCancelClick) } } } + + if (showCancelAllConfirm && onCancelAllClick != null) { + AlertDialog( + onDismissRequest = { showCancelAllConfirm = false }, + title = { Text(stringResource(R.string.open_sells_cancel_all_confirm_title)) }, + text = { + Text(stringResource(R.string.open_sells_cancel_all_confirm_text, openSells.size)) + }, + confirmButton = { + TextButton(onClick = { + showCancelAllConfirm = false + onCancelAllClick() + }) { + Text(stringResource(R.string.open_sells_cancel_all), color = Error) + } + }, + dismissButton = { + TextButton(onClick = { showCancelAllConfirm = false }) { + Text(stringResource(R.string.open_sells_cancel_back)) + } + } + ) + } } /** @@ -107,13 +150,18 @@ internal fun OpenSellRow( ) if (tx.status == TransactionStatus.PARTIAL) { Text( - text = "Castecne: $progressPct% (${NumberFormatters.crypto(filled)} / ${NumberFormatters.crypto(requested)})", + text = stringResource( + R.string.open_sells_status_partial, + progressPct.toString(), + NumberFormatters.crypto(filled), + NumberFormatters.crypto(requested) + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.tertiary ) } else { Text( - text = "Ceka na vyplneni", + text = stringResource(R.string.open_sells_status_pending), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -122,7 +170,7 @@ internal fun OpenSellRow( IconButton(onClick = { showConfirm = true }) { Icon( imageVector = Icons.Default.Close, - contentDescription = "Zrusit order", + contentDescription = stringResource(R.string.open_sells_cancel_action), tint = Error ) } @@ -132,10 +180,16 @@ internal fun OpenSellRow( val priceText = tx.limitPrice?.let { NumberFormatters.fiat(it) } ?: "-" AlertDialog( onDismissRequest = { showConfirm = false }, - title = { Text("Zrusit order?") }, + title = { Text(stringResource(R.string.open_sells_cancel_confirm_title)) }, text = { Text( - "Opravdu zrusit limitni prodej ${NumberFormatters.crypto(requested)} ${tx.crypto} @ $priceText ${tx.fiat}?" + stringResource( + R.string.open_sells_cancel_confirm_text, + NumberFormatters.crypto(requested), + tx.crypto, + priceText, + tx.fiat + ) ) }, confirmButton = { @@ -143,12 +197,12 @@ internal fun OpenSellRow( showConfirm = false onCancelClick(tx.id) }) { - Text("Zrusit order", color = Error) + Text(stringResource(R.string.open_sells_cancel_action), color = Error) } }, dismissButton = { TextButton(onClick = { showConfirm = false }) { - Text("Zpet") + Text(stringResource(R.string.open_sells_cancel_back)) } } ) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index f44f0d4..28b0e39 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -110,7 +110,7 @@ fun SellWizardBottomSheet( state.ladderOutcome?.let { outcome -> AlertDialog( onDismissRequest = viewModel::consumeLadderOutcome, - title = { Text(stringResource(R.string.sell_wizard_ladder_enable)) }, + title = { Text(stringResource(R.string.sell_wizard_ladder_dialog_title)) }, text = { Text( if (outcome.reason == null) { diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 5464fb6..9b90cb8 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -446,6 +446,7 @@ Zkopírováno do schránky Dokončeno Selhalo + Zrušeno Čekající Částečně vyplněno v @@ -1018,15 +1019,26 @@ Prodáváš pod nákupní cenou: ztráta %1$s %2$s Po fee jdeš do ztráty: %1$s %2$s Inventář nesedí (chybí %1$s) - zadej průměrnou nákupní cenu ručně - Vytvořit více sell orderů (ladder) + Rozdělit na více příkazů Od Do - Počet orderů + Počet příkazů Cena - Profit % + Zisk % Stejné množství Stejný výnos - Celkem při plném fillu - Vytvořeno všech %1$d orderů - Vytvořeno %1$d z %2$d. Zastaveno: %3$s + Celkem při plném vyplnění + Vytvořeno všech %1$d příkazů + Vytvořeno %1$d z %2$d příkazů. Zastaveno: %3$s + Výsledek + Otevřené prodejní příkazy (%1$d) + Částečně: %1$s %% (%2$s / %3$s) + Čeká na vyplnění + Zrušit příkaz + Zrušit příkaz? + Opravdu zrušit limitní prodej %1$s %2$s @ %3$s %4$s? + Zpět + Zrušit vše + Zrušit všechny příkazy? + Opravdu zrušit všechny otevřené prodejní příkazy (%1$d)? Akci nelze vrátit. diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index b5dd9e2..09970d2 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -445,6 +445,7 @@ Copied to clipboard Completed Failed + Cancelled Pending Partially Filled at @@ -1012,7 +1013,7 @@ Selling below buy price: %1$s %2$s loss After fee, this is a loss: %1$s %2$s Inventory mismatch (%1$s missing) - enter avg buy price manually - Create multiple sell orders (ladder) + Split into multiple orders From To Number of orders @@ -1023,4 +1024,15 @@ Total at full fill All %1$d orders placed Placed %1$d of %2$d. Stopped: %3$s + Result + Open sell orders (%1$d) + Partial: %1$s %% (%2$s / %3$s) + Waiting for fill + Cancel order + Cancel order? + Cancel limit sell %1$s %2$s @ %3$s %4$s? + Back + Cancel all + Cancel all orders? + Cancel all %1$d open sell orders? This cannot be undone. From e5e9dcf72ebec8113749726328f65c1c2881cc16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 10 May 2026 16:37:50 +0200 Subject: [PATCH 61/75] feat(sell): smooth single<->ladder toggle preserves intent - Single -> Ladder: single limit price becomes the midpoint of the range, From/To = price * 0.90 .. price * 1.10. With default count 5, the middle order matches the price the user already set; the rest spread +-10 % around it. Range fields populated in whatever mode (Cena / Zisk %) is currently active. - Ladder -> Single: keep total net from the ladder preview, derive the single limit price as net / (amount * (1 - fee)). User keeps the same expected proceeds with one order instead of N. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../screens/plans/sell/SellWizardViewModel.kt | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt index dcd901b..6eef2f8 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt @@ -314,8 +314,65 @@ class SellWizardViewModel @Inject constructor( // --- Ladder handlers --- fun setLadderEnabled(enabled: Boolean) { - _uiState.update { it.copy(ladderEnabled = enabled, ladderOutcome = null) } - recomputeLadderPreview() + _uiState.update { st -> + if (enabled) { + // Single -> Ladder: use the single limit price as the midpoint of the + // new range and spread +-10 % around it. Default count (5) gives orders + // at 0.90, 0.95, 1.00, 1.05, 1.10 x price -> middle order matches the + // single price the user had already set. + val price = st.priceInput.toBigDecimalOrNull() + val avg = st.avgBuyPrice + val (fromInput, toInput) = if (price != null && price > BigDecimal.ZERO) { + val fromPrice = price * BigDecimal("0.90") + val toPrice = price * BigDecimal("1.10") + when (st.ladderRangeMode) { + LadderRangeMode.PRICE -> + fromPrice.setScale(2, RoundingMode.HALF_UP).toPlainString() to + toPrice.setScale(2, RoundingMode.HALF_UP).toPlainString() + LadderRangeMode.PROFIT_PCT -> { + if (avg != null && avg > BigDecimal.ZERO) { + val fromPct = (fromPrice - avg).divide(avg, 6, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)).setScale(2, RoundingMode.HALF_UP).toPlainString() + val toPct = (toPrice - avg).divide(avg, 6, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)).setScale(2, RoundingMode.HALF_UP).toPlainString() + fromPct to toPct + } else st.ladderFromInput to st.ladderToInput + } + } + } else st.ladderFromInput to st.ladderToInput + st.copy( + ladderEnabled = true, + ladderOutcome = null, + ladderFromInput = fromInput, + ladderToInput = toInput + ) + } else { + // Ladder -> Single: derive a single price that yields the same total net + // for the same crypto amount: net = amount * price * (1 - fee), so + // price = net / (amount * (1 - fee)). Net comes from current ladder total + // (already in netInput, kept in sync by recomputeLadderPreview). + val amount = st.amountInput.toBigDecimalOrNull() + val total = st.netInput.toBigDecimalOrNull() + val factor = BigDecimal.ONE - st.feeRate + val newPrice = if (amount != null && total != null && + amount > BigDecimal.ZERO && factor > BigDecimal.ZERO + ) { + total.divide(amount * factor, 2, RoundingMode.HALF_UP).toPlainString() + } else st.priceInput + st.copy( + ladderEnabled = false, + ladderOutcome = null, + priceInput = newPrice, + ladderPreview = emptyList(), + ladderHardError = null + ) + } + } + if (_uiState.value.ladderEnabled) { + recomputeLadderPreview() + } else { + revalidate() + } } fun setLadderRangeMode(mode: LadderRangeMode) { From 0987ce2bf50f16a02d6ed6514c580ed45b86c3b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 10 May 2026 16:43:44 +0200 Subject: [PATCH 62/75] fix(sell): localize ladder preview table headers (Profit/Net) cs: Zisk / Vynos, en: Profit / Net. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../presentation/screens/plans/sell/SellWizardBottomSheet.kt | 4 ++-- accbot-android/app/src/main/res/values-cs/strings.xml | 2 ++ accbot-android/app/src/main/res/values/strings.xml | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index 28b0e39..4a8eb0e 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -816,8 +816,8 @@ private fun LadderPreviewTable( Text("#", modifier = Modifier.weight(0.4f), style = MaterialTheme.typography.labelSmall) Text(stringResource(R.string.sell_wizard_amount), modifier = Modifier.weight(1.4f), style = MaterialTheme.typography.labelSmall) Text(stringResource(R.string.sell_wizard_limit_price), modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.labelSmall) - Text("Profit", modifier = Modifier.weight(1f), style = MaterialTheme.typography.labelSmall) - Text("Net", modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.labelSmall) + Text(stringResource(R.string.sell_wizard_ladder_table_profit), modifier = Modifier.weight(1f), style = MaterialTheme.typography.labelSmall) + Text(stringResource(R.string.sell_wizard_ladder_table_net), modifier = Modifier.weight(1.6f), style = MaterialTheme.typography.labelSmall) } androidx.compose.material3.HorizontalDivider() var totalNet = BigDecimal.ZERO diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 9b90cb8..c442ad5 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -1031,6 +1031,8 @@ Vytvořeno všech %1$d příkazů Vytvořeno %1$d z %2$d příkazů. Zastaveno: %3$s Výsledek + Zisk + Výnos Otevřené prodejní příkazy (%1$d) Částečně: %1$s %% (%2$s / %3$s) Čeká na vyplnění diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 09970d2..6680c96 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -1025,6 +1025,8 @@ All %1$d orders placed Placed %1$d of %2$d. Stopped: %3$s Result + Profit + Net Open sell orders (%1$d) Partial: %1$s %% (%2$s / %3$s) Waiting for fill From 3bcb96f764b849a5a15457a1c967a88605cd866a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 10 May 2026 17:09:30 +0200 Subject: [PATCH 63/75] refactor(sell): audit cleanup - race fix, dead code, dedup Targeted fixes from independent code review: Correctness: - Fix race in revalidate(): cancel previous Job before launching next so a slower earlier validation can't overwrite newer keystroke results. - Fix avg-buy "manual" flag wrongly true after prefill (compared 8dp auto vs 2dp display - always reported manual). - Fix LadderPreviewTable totalNet shadowing: was showing profit when avg known, but label said "total net". Now consistently net. - Convert lossPct from Double to BigDecimal (no Double in money math). Cleanup: - Drop SellValidation.Ok singleton (empty list = valid, simpler API). - Delete dead code: setPriceBreakeven, setPriceSpotPlus. - Remove unused imports (TransactionSide, TransactionStatus, Exchange). - Magic timeouts -> named constants (INIT/SUBMIT_SINGLE/SUBMIT_LADDER). - PlaceLadderSellUseCase: silent catch -> Log.w on resolvePending failure. - Rename `drobky` -> `remainder` (English consistency). - Drop orphaned strings sell_wizard_chip_breakeven, sell_wizard_profit_vs_avg. DRY: - Extract LadderGenerator.priceToProfitPct / profitPctToPrice - same formula was duplicated 5x across setLadderEnabled, setLadderRangeMode, setLadderNetTarget, recomputeLadderPreview. --- .../domain/usecase/PlaceLadderSellUseCase.kt | 11 ++- .../usecase/ValidateSellOrderUseCase.kt | 13 ++- .../screens/plans/sell/LadderGenerator.kt | 19 +++- .../plans/sell/SellWizardBottomSheet.kt | 3 +- .../screens/plans/sell/SellWizardViewModel.kt | 89 +++++++------------ .../app/src/main/res/values-cs/strings.xml | 2 - .../app/src/main/res/values/strings.xml | 2 - 7 files changed, 68 insertions(+), 71 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt index 034d403..cbed968 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt @@ -1,5 +1,6 @@ package com.accbot.dca.domain.usecase +import android.util.Log import com.accbot.dca.data.local.CredentialsStore import com.accbot.dca.data.local.DcaDatabase import com.accbot.dca.data.local.UserPreferences @@ -62,7 +63,15 @@ class PlaceLadderSellUseCase @Inject constructor( } } - try { resolvePendingTransactionsUseCase() } catch (_: Exception) {} + try { + resolvePendingTransactionsUseCase() + } catch (e: Exception) { + Log.w(TAG, "resolvePending after ladder failed: ${e.message}") + } return LadderResult.AllPlaced(placed) } + + companion object { + private const val TAG = "PlaceLadderSellUseCase" + } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt index 04caf65..50ea370 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt @@ -9,13 +9,12 @@ import javax.inject.Inject /** * Validation outcome for a prospective sell order. Multiple items may be returned * (e.g. InstantFillInfo and no hard error), so callers should render each item. - * [Ok] is only emitted when the list would otherwise be empty. + * An empty list means "valid". */ sealed class SellValidation { /** Field this validation result attaches to (lets UI render it under the right input). */ enum class Field { AMOUNT, PRICE, NET, GENERIC } - object Ok : SellValidation() data class HardError(val message: String, val field: Field = Field.GENERIC) : SellValidation() data class InstantFillInfo(val spot: BigDecimal) : SellValidation() data class FarFromMarketWarning(val spot: BigDecimal) : SellValidation() @@ -25,7 +24,7 @@ sealed class SellValidation { * pushes the result into negative territory. Caller (UI) shows a warning banner; * the wizard still allows the user to proceed - the user makes the final decision. */ - data class LossWarning(val lossFiat: BigDecimal, val lossPct: Double) : SellValidation() + data class LossWarning(val lossFiat: BigDecimal, val lossPct: BigDecimal) : SellValidation() } /** @@ -110,7 +109,6 @@ class ValidateSellOrderUseCase @Inject constructor( checkLoss(cryptoAmount, limitPrice, avgBuyPrice, feeRate)?.let { result += it } - if (result.isEmpty()) result += SellValidation.Ok return result } @@ -133,10 +131,11 @@ class ValidateSellOrderUseCase @Inject constructor( val costBasis = cryptoAmount * avgBuyPrice val netProfit = netFiat - costBasis if (netProfit >= BigDecimal.ZERO) return null + val lossFiat = netProfit.negate() val lossPct = if (costBasis > BigDecimal.ZERO) { - netProfit.toDouble() / costBasis.toDouble() - } else 0.0 - return SellValidation.LossWarning(lossFiat = netProfit.negate(), lossPct = -lossPct) + lossFiat.divide(costBasis, 4, java.math.RoundingMode.HALF_UP) + } else BigDecimal.ZERO + return SellValidation.LossWarning(lossFiat = lossFiat, lossPct = lossPct) } } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/LadderGenerator.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/LadderGenerator.kt index 70490c4..1460035 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/LadderGenerator.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/LadderGenerator.kt @@ -18,6 +18,21 @@ object LadderGenerator { enum class AmountMode { EQUAL_CRYPTO, EQUAL_FIAT } + /** `pct = (price - avg) / avg * 100`. Returns null when avg is missing or zero. */ + fun priceToProfitPct(price: BigDecimal, avg: BigDecimal?): BigDecimal? { + if (avg == null || avg <= BigDecimal.ZERO) return null + return (price - avg).divide(avg, 6, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + .setScale(2, RoundingMode.HALF_UP) + } + + /** `price = avg * (1 + pct/100)`. Returns null when avg is missing or zero. */ + fun profitPctToPrice(pct: BigDecimal, avg: BigDecimal?): BigDecimal? { + if (avg == null || avg <= BigDecimal.ZERO) return null + return (avg * (BigDecimal.ONE + pct.divide(BigDecimal(100), 8, RoundingMode.HALF_UP))) + .setScale(2, RoundingMode.HALF_UP) + } + fun generate( totalAmount: BigDecimal, from: BigDecimal, @@ -38,9 +53,9 @@ object LadderGenerator { return when (mode) { AmountMode.EQUAL_CRYPTO -> { val per = totalAmount.divide(n, 8, RoundingMode.DOWN) - val drobky = totalAmount - per * n + val remainder = totalAmount - per * n prices.mapIndexed { i, p -> - val a = if (i == count - 1) per + drobky else per + val a = if (i == count - 1) per + remainder else per LadderOrder(a, p) } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index 4a8eb0e..35f8842 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -500,7 +500,6 @@ private fun SellInputStep( stringResource(R.string.sell_wizard_far_from_market_warning) ) is SellValidation.LossWarning -> { /* shown via LossBanner in summary section */ } - is SellValidation.Ok -> { /* no-op */ } } } @@ -829,7 +828,7 @@ private fun LadderPreviewTable( .multiply(BigDecimal(100)).setScale(1, RoundingMode.HALF_UP) "${if (pct.signum() >= 0) "+" else ""}${pct.toPlainString()} %" } else "-" - totalNet += if (avg != null) net - o.cryptoAmount * avg else net + totalNet += net Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { Text("${i + 1}", modifier = Modifier.weight(0.4f), style = MaterialTheme.typography.bodySmall) Text(NumberFormatters.crypto(o.cryptoAmount), modifier = Modifier.weight(1.4f), style = MaterialTheme.typography.bodySmall) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt index 6eef2f8..f39cb8e 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt @@ -6,10 +6,7 @@ import androidx.lifecycle.viewModelScope import com.accbot.dca.data.local.CredentialsStore import com.accbot.dca.data.local.DcaDatabase import com.accbot.dca.data.local.UserPreferences -import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.RemainingInventory -import com.accbot.dca.domain.model.TransactionSide -import com.accbot.dca.domain.model.TransactionStatus import com.accbot.dca.domain.usecase.CalculatePlanCostBasisUseCase import com.accbot.dca.domain.usecase.CalculatePlanPnLUseCase import com.accbot.dca.domain.usecase.LadderOrder @@ -21,6 +18,7 @@ import com.accbot.dca.domain.usecase.ValidateSellOrderUseCase import com.accbot.dca.exchange.ExchangeApiFactory import com.accbot.dca.exchange.MinOrderSizeRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -124,6 +122,7 @@ class SellWizardViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private var initialized = false + private var validationJob: Job? = null fun init(planId: Long) { if (initialized) return @@ -144,7 +143,7 @@ class SellWizardViewModel @Inject constructor( val api = credentials?.let { exchangeApiFactory.create(it) } val spot = api?.let { try { - withTimeoutOrNull(10_000) { it.getCurrentPrice(plan.crypto, plan.fiat) } + withTimeoutOrNull(INIT_TIMEOUT_MS) { it.getCurrentPrice(plan.crypto, plan.fiat) } } catch (e: Exception) { Log.w(TAG, "getCurrentPrice failed: ${e.message}") null @@ -225,12 +224,6 @@ class SellWizardViewModel @Inject constructor( } } - fun setPriceBreakeven() { - _uiState.value.avgBuyPrice?.let { - setPrice(it.setScale(2, RoundingMode.HALF_UP).toPlainString()) - } - } - fun setPriceAvgPlus(pct: Int) { _uiState.value.avgBuyPrice?.let { avg -> val multiplier = BigDecimal.ONE + @@ -239,14 +232,6 @@ class SellWizardViewModel @Inject constructor( } } - fun setPriceSpotPlus(pct: Int) { - _uiState.value.spotPrice?.let { spot -> - val multiplier = BigDecimal.ONE + - BigDecimal(pct).divide(BigDecimal(100), 4, RoundingMode.HALF_UP) - setPrice((spot * multiplier).setScale(2, RoundingMode.HALF_UP).toPlainString()) - } - } - /** Apply a profit-target preset to the net field: N = A * avg * (1 + profitPct). */ fun applyNetProfitPreset(profitPct: Double) { val st = _uiState.value @@ -260,7 +245,11 @@ class SellWizardViewModel @Inject constructor( fun setAvgBuyPrice(value: String) { _uiState.update { st -> val parsed = value.toBigDecimalOrNull() - val isManual = parsed != null && parsed.compareTo(st.avgBuyPriceAuto ?: BigDecimal("-1")) != 0 + // Compare against the same 2dp display rounding the user sees - avoids + // flagging "manual" on the very first prefill that came from auto. + val autoDisplay = st.avgBuyPriceAuto?.setScale(2, RoundingMode.HALF_UP) + val isManual = parsed != null && + (autoDisplay == null || parsed.compareTo(autoDisplay) != 0) st.copy(avgBuyPriceInput = value, avgBuyPriceManual = isManual) } revalidate() @@ -316,10 +305,8 @@ class SellWizardViewModel @Inject constructor( fun setLadderEnabled(enabled: Boolean) { _uiState.update { st -> if (enabled) { - // Single -> Ladder: use the single limit price as the midpoint of the - // new range and spread +-10 % around it. Default count (5) gives orders - // at 0.90, 0.95, 1.00, 1.05, 1.10 x price -> middle order matches the - // single price the user had already set. + // Single -> Ladder: spread +-10 % around the single price so the middle + // order matches what the user had already set. val price = st.priceInput.toBigDecimalOrNull() val avg = st.avgBuyPrice val (fromInput, toInput) = if (price != null && price > BigDecimal.ZERO) { @@ -330,12 +317,10 @@ class SellWizardViewModel @Inject constructor( fromPrice.setScale(2, RoundingMode.HALF_UP).toPlainString() to toPrice.setScale(2, RoundingMode.HALF_UP).toPlainString() LadderRangeMode.PROFIT_PCT -> { - if (avg != null && avg > BigDecimal.ZERO) { - val fromPct = (fromPrice - avg).divide(avg, 6, RoundingMode.HALF_UP) - .multiply(BigDecimal(100)).setScale(2, RoundingMode.HALF_UP).toPlainString() - val toPct = (toPrice - avg).divide(avg, 6, RoundingMode.HALF_UP) - .multiply(BigDecimal(100)).setScale(2, RoundingMode.HALF_UP).toPlainString() - fromPct to toPct + val fromPct = LadderGenerator.priceToProfitPct(fromPrice, avg) + val toPct = LadderGenerator.priceToProfitPct(toPrice, avg) + if (fromPct != null && toPct != null) { + fromPct.toPlainString() to toPct.toPlainString() } else st.ladderFromInput to st.ladderToInput } } @@ -381,18 +366,13 @@ class SellWizardViewModel @Inject constructor( val avg = st.avgBuyPrice val convert: (String) -> String = { input -> val v = input.toBigDecimalOrNull() - if (v == null || avg == null || avg <= BigDecimal.ZERO) "" - else when (mode) { - // PROFIT_PCT -> PRICE: price = avg * (1 + pct/100) - LadderRangeMode.PRICE -> - (avg * (BigDecimal.ONE + v.divide(BigDecimal(100), 8, RoundingMode.HALF_UP))) - .setScale(2, RoundingMode.HALF_UP).toPlainString() - // PRICE -> PROFIT_PCT: pct = (price - avg) / avg * 100 - LadderRangeMode.PROFIT_PCT -> - (v - avg).divide(avg, 6, RoundingMode.HALF_UP) - .multiply(BigDecimal(100)) - .setScale(2, RoundingMode.HALF_UP).toPlainString() + val converted = v?.let { + when (mode) { + LadderRangeMode.PRICE -> LadderGenerator.profitPctToPrice(it, avg) + LadderRangeMode.PROFIT_PCT -> LadderGenerator.priceToProfitPct(it, avg) + } } + converted?.toPlainString() ?: "" } st.copy( ladderRangeMode = mode, @@ -441,22 +421,15 @@ class SellWizardViewModel @Inject constructor( val fromRaw = st.ladderFromInput.toBigDecimalOrNull() ?: return val fromPrice = when (st.ladderRangeMode) { LadderRangeMode.PRICE -> fromRaw - LadderRangeMode.PROFIT_PCT -> { - if (avg == null || avg <= BigDecimal.ZERO) return - avg * (BigDecimal.ONE + fromRaw.divide(BigDecimal(100), 8, RoundingMode.HALF_UP)) - } + LadderRangeMode.PROFIT_PCT -> LadderGenerator.profitPctToPrice(fromRaw, avg) ?: return } if (fromPrice <= BigDecimal.ZERO) return val toPrice = (BigDecimal(2) * targetNet).divide(amount * factor, 8, RoundingMode.HALF_UP) - fromPrice if (toPrice <= fromPrice) return val toDisplay = when (st.ladderRangeMode) { LadderRangeMode.PRICE -> toPrice.setScale(2, RoundingMode.HALF_UP).toPlainString() - LadderRangeMode.PROFIT_PCT -> { - if (avg == null || avg <= BigDecimal.ZERO) return - (toPrice - avg).divide(avg, 6, RoundingMode.HALF_UP) - .multiply(BigDecimal(100)) - .setScale(2, RoundingMode.HALF_UP).toPlainString() - } + LadderRangeMode.PROFIT_PCT -> + LadderGenerator.priceToProfitPct(toPrice, avg)?.toPlainString() ?: return } _uiState.update { it.copy(ladderToInput = toDisplay) } recomputeLadderPreview(syncNetFromTotal = false) @@ -484,8 +457,8 @@ class SellWizardViewModel @Inject constructor( } val fPct = st.ladderFromInput.toBigDecimalOrNull() val tPct = st.ladderToInput.toBigDecimalOrNull() - val f = fPct?.let { avg * (BigDecimal.ONE + it.divide(BigDecimal(100), 8, RoundingMode.HALF_UP)) } - val t = tPct?.let { avg * (BigDecimal.ONE + it.divide(BigDecimal(100), 8, RoundingMode.HALF_UP)) } + val f = fPct?.let { LadderGenerator.profitPctToPrice(it, avg) } + val t = tPct?.let { LadderGenerator.profitPctToPrice(it, avg) } f to t } } @@ -535,7 +508,7 @@ class SellWizardViewModel @Inject constructor( if (!st.ladderEnabled || st.ladderPreview.size < 2) return viewModelScope.launch { _uiState.update { it.copy(submitting = true, submitError = null, ladderOutcome = null) } - val result = withTimeoutOrNull(30_000L) { + val result = withTimeoutOrNull(SUBMIT_LADDER_TIMEOUT_MS) { placeLadderSellUseCase(st.planId, st.ladderPreview) } when (result) { @@ -588,7 +561,7 @@ class SellWizardViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(submitting = true, submitError = null) } - val result = withTimeoutOrNull(15_000L) { + val result = withTimeoutOrNull(SUBMIT_SINGLE_TIMEOUT_MS) { placeLimitSellUseCase(state.planId, amount, price) } @@ -620,11 +593,14 @@ class SellWizardViewModel @Inject constructor( val state = _uiState.value val amount = state.amountInput.toBigDecimalOrNull() val price = state.priceInput.toBigDecimalOrNull() + validationJob?.cancel() if (amount == null || price == null) { _uiState.update { it.copy(validations = emptyList()) } return } - viewModelScope.launch { + // Cancel any in-flight validation so a slower earlier coroutine can't overwrite + // newer results - keystrokes fire revalidate() rapidly and we must keep the latest win. + validationJob = viewModelScope.launch { val validations = try { validateSellOrderUseCase( state.planId, amount, price, state.minOrderFiat, state.spotPrice, @@ -640,5 +616,8 @@ class SellWizardViewModel @Inject constructor( companion object { private const val TAG = "SellWizardViewModel" + private const val INIT_TIMEOUT_MS = 10_000L + private const val SUBMIT_SINGLE_TIMEOUT_MS = 15_000L + private const val SUBMIT_LADDER_TIMEOUT_MS = 30_000L } } diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index c442ad5..7f46382 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -984,12 +984,10 @@ K dispozici: Množství k prodeji Vše - Bez ztráty Limitní cena Tržní Souhrn Získáte: - Zisk vs průměr: Prodej proběhne okamžitě. Limitní cena je pod aktuální tržní (%1$s %2$s). Příkaz se vyplní ihned za nejvyšší nabídku na burze (obvykle blízko tržní ceny minus spread). Není to chyba. Cena je vysoko nad trhem - prodej se nemusí vyplnit dlouho. Pokračovat diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 6680c96..28a44f3 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -978,12 +978,10 @@ Available: Amount to sell All - Breakeven Limit price Market Summary Proceeds: - Profit vs avg: The sell will execute immediately. The limit price is below the current market price (%1$s %2$s). The order will fill at the highest bid on the exchange (usually close to market price minus spread). This is not an error. The price is far above market - the order may take a long time to fill. Continue From 3a9a83de499c6817a2c7e773ea81b98c30c42411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 10 May 2026 17:19:24 +0200 Subject: [PATCH 64/75] refactor(sell): localize errors via sealed classes, share cost basis, locale-aware grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Localization (CZ strings out of code): - SellValidation.HardError -> sealed subclasses (AmountMustBePositive, PriceMustBePositive, MinOrderTooLow, InsufficientInventory). UI resolves text via Composable HardError.localizedText(). - New sealed LadderError (AvgRequired, AmountMustBePositive, CountMin2, ToMustExceedFrom, Insufficient, BelowMin) replaces String? ladderHardError. - @ApplicationContext in SellWizardViewModel for "Unknown error" fallback. - Hardcoded CZ strings (validation, ladder, "= X % z dostupných") moved to strings.xml (EN+CS). Reuse: - ValidateSellOrderUseCase now uses CalculatePlanCostBasisUseCase for the available-to-sell check. Single source of truth, drops 25 lines of duplicated held/openSellsRequested logic. - LadderControls uses SelectableChip (matches HistoryScreen, ScheduleBuilder) instead of custom AssistChip styling. - ThousandSeparator visual transform now locale-aware via DecimalFormatSymbols (CS: nbsp, EN: ',') instead of hardcoded ASCII space. --- .../usecase/ValidateSellOrderUseCase.kt | 55 ++++------- .../plans/sell/SellWizardBottomSheet.kt | 91 ++++++++++++------- .../screens/plans/sell/SellWizardViewModel.kt | 38 ++++++-- .../app/src/main/res/values-cs/strings.xml | 12 +++ .../app/src/main/res/values/strings.xml | 12 +++ 5 files changed, 126 insertions(+), 82 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt index 50ea370..3821db2 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateSellOrderUseCase.kt @@ -1,8 +1,5 @@ package com.accbot.dca.domain.usecase -import com.accbot.dca.data.local.DcaDatabase -import com.accbot.dca.domain.model.TransactionSide -import com.accbot.dca.domain.model.TransactionStatus import java.math.BigDecimal import javax.inject.Inject @@ -10,12 +7,20 @@ import javax.inject.Inject * Validation outcome for a prospective sell order. Multiple items may be returned * (e.g. InstantFillInfo and no hard error), so callers should render each item. * An empty list means "valid". + * + * Hard errors are locale-free: each subtype maps to a string resource the UI resolves. */ sealed class SellValidation { /** Field this validation result attaches to (lets UI render it under the right input). */ enum class Field { AMOUNT, PRICE, NET, GENERIC } - data class HardError(val message: String, val field: Field = Field.GENERIC) : SellValidation() + sealed class HardError(val field: Field) : SellValidation() { + object AmountMustBePositive : HardError(Field.AMOUNT) + object PriceMustBePositive : HardError(Field.PRICE) + data class MinOrderTooLow(val minOrderFiat: BigDecimal) : HardError(Field.AMOUNT) + data class InsufficientInventory(val available: BigDecimal) : HardError(Field.AMOUNT) + } + data class InstantFillInfo(val spot: BigDecimal) : SellValidation() data class FarFromMarketWarning(val spot: BigDecimal) : SellValidation() /** @@ -39,7 +44,7 @@ sealed class SellValidation { * - limitPrice > 3x spot -> FarFromMarketWarning (typo protection) */ class ValidateSellOrderUseCase @Inject constructor( - private val database: DcaDatabase + private val calculatePlanCostBasisUseCase: CalculatePlanCostBasisUseCase ) { suspend operator fun invoke( planId: Long, @@ -54,48 +59,22 @@ class ValidateSellOrderUseCase @Inject constructor( val result = mutableListOf() if (cryptoAmount <= BigDecimal.ZERO) { - result += SellValidation.HardError("Množství musí být větší než 0", SellValidation.Field.AMOUNT) + result += SellValidation.HardError.AmountMustBePositive return result } if (limitPrice <= BigDecimal.ZERO) { - result += SellValidation.HardError("Limitní cena musí být větší než 0", SellValidation.Field.PRICE) + result += SellValidation.HardError.PriceMustBePositive return result } if (minOrderFiat > BigDecimal.ZERO && cryptoAmount * limitPrice < minOrderFiat) { - result += SellValidation.HardError( - "Minimální hodnota orderu je $minOrderFiat (zvyš množství nebo cenu)", - SellValidation.Field.AMOUNT - ) - } - - val tx = database.transactionDao().getTransactionsByPlanSync(planId) - val completedOrPartial = tx.filter { - it.status == TransactionStatus.COMPLETED || it.status == TransactionStatus.PARTIAL + result += SellValidation.HardError.MinOrderTooLow(minOrderFiat) } - val heldBought = completedOrPartial - .filter { it.side == TransactionSide.BUY } - .fold(BigDecimal.ZERO) { acc, t -> acc + t.cryptoAmount } - val heldSold = completedOrPartial - .filter { it.side == TransactionSide.SELL } - .fold(BigDecimal.ZERO) { acc, t -> acc + t.cryptoAmount } - val held = heldBought - heldSold - - // Unfilled crypto reserved by other open sells (PENDING or PARTIAL). - val openSellsRequested = tx - .filter { - it.side == TransactionSide.SELL && - it.status in setOf(TransactionStatus.PENDING, TransactionStatus.PARTIAL) - } - .fold(BigDecimal.ZERO) { acc, t -> - acc + ((t.requestedCryptoAmount ?: BigDecimal.ZERO) - t.cryptoAmount) - } - val available = held - openSellsRequested + // Single source of truth for "available to sell" - the cost basis use case already + // accounts for filled buys, filled sells, and full reservation of PENDING/PARTIAL sells. + val available = calculatePlanCostBasisUseCase(planId).available if (cryptoAmount > available) { - result += SellValidation.HardError( - "Nemáš tolik k dispozici (k dispozici $available)", - SellValidation.Field.AMOUNT - ) + result += SellValidation.HardError.InsufficientInventory(available) } if (currentSpot != null) { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index 35f8842..0d02d29 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -64,10 +64,40 @@ import com.accbot.dca.presentation.utils.NumberFormatters import java.math.BigDecimal import java.math.RoundingMode +@Composable +private fun SellValidation.HardError.localizedText(): String = when (this) { + SellValidation.HardError.AmountMustBePositive -> + stringResource(R.string.sell_validation_amount_must_be_positive) + SellValidation.HardError.PriceMustBePositive -> + stringResource(R.string.sell_validation_price_must_be_positive) + is SellValidation.HardError.MinOrderTooLow -> + stringResource(R.string.sell_validation_min_order_too_low, NumberFormatters.fiat(minOrderFiat)) + is SellValidation.HardError.InsufficientInventory -> + stringResource(R.string.sell_validation_insufficient_inventory, NumberFormatters.crypto(available)) +} + +@Composable +private fun LadderError.localizedText(): String = when (this) { + LadderError.AvgRequired -> + stringResource(R.string.sell_wizard_ladder_error_avg_required) + LadderError.AmountMustBePositive -> + stringResource(R.string.sell_wizard_ladder_error_amount_positive) + LadderError.CountMin2 -> + stringResource(R.string.sell_wizard_ladder_error_count_min2) + LadderError.ToMustExceedFrom -> + stringResource(R.string.sell_wizard_ladder_error_to_must_exceed_from) + LadderError.Insufficient -> + stringResource(R.string.sell_wizard_ladder_error_insufficient) + is LadderError.BelowMin -> stringResource( + R.string.sell_wizard_ladder_error_below_min, + NumberFormatters.fiat(smallest), fiat, NumberFormatters.fiat(min) + ) +} + /** * Two-step bottom sheet for placing a limit sell order: * 1. INPUT - amount + price with quick-set chips, live validations and summary - * 2. CONFIRM - read-only summary + warning + submit (added in Task 25) + * 2. CONFIRM - read-only summary + warning + submit */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -258,8 +288,8 @@ private fun SellInputStep( isError = amountError != null, supportingText = { when { - amountError != null -> Text(amountError.message, color = MaterialTheme.colorScheme.error) - amountPct != null -> Text("= ${amountPct.toPlainString()} % z dostupných") + amountError != null -> Text(amountError.localizedText(), color = MaterialTheme.colorScheme.error) + amountPct != null -> Text(stringResource(R.string.sell_wizard_amount_pct_hint, amountPct.toPlainString())) } }, trailingIcon = { @@ -318,8 +348,8 @@ private fun SellInputStep( onValueChange = vm::setPrice, visualTransformation = ThousandSeparator, isError = priceError != null, - supportingText = priceError?.let { - { Text(it.message, color = MaterialTheme.colorScheme.error) } + supportingText = priceError?.let { err -> + { Text(err.localizedText(), color = MaterialTheme.colorScheme.error) } }, trailingIcon = { Text( @@ -373,8 +403,8 @@ private fun SellInputStep( onValueChange = if (state.ladderEnabled) vm::setLadderNetTarget else vm::setNetFiat, visualTransformation = ThousandSeparator, isError = !state.ladderEnabled && netError != null, - supportingText = netError?.takeIf { !state.ladderEnabled }?.let { - { Text(it.message, color = MaterialTheme.colorScheme.error) } + supportingText = netError?.takeIf { !state.ladderEnabled }?.let { err -> + { Text(err.localizedText(), color = MaterialTheme.colorScheme.error) } }, trailingIcon = { Text( @@ -487,7 +517,7 @@ private fun SellInputStep( when (v) { is SellValidation.HardError -> if (v.field == SellValidation.Field.GENERIC) { Text( - text = v.message, + text = v.localizedText(), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(vertical = 4.dp) @@ -712,15 +742,10 @@ private fun LadderControls(state: SellWizardViewModel.UiState, vm: SellWizardVie SellWizardViewModel.LadderRangeMode.PROFIT_PCT to stringResource(R.string.sell_wizard_ladder_range_profit), SellWizardViewModel.LadderRangeMode.PRICE to stringResource(R.string.sell_wizard_ladder_range_price) ).forEach { (mode, label) -> - AssistChip( - onClick = { vm.setLadderRangeMode(mode) }, - label = { Text(label) }, - colors = if (state.ladderRangeMode == mode) { - androidx.compose.material3.AssistChipDefaults.assistChipColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - labelColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - } else androidx.compose.material3.AssistChipDefaults.assistChipColors() + com.accbot.dca.presentation.components.SelectableChip( + text = label, + selected = state.ladderRangeMode == mode, + onClick = { vm.setLadderRangeMode(mode) } ) } } @@ -754,7 +779,7 @@ private fun LadderControls(state: SellWizardViewModel.UiState, vm: SellWizardVie } state.ladderHardError?.let { err -> Text( - err, + err.localizedText(), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp, start = 16.dp) @@ -779,15 +804,10 @@ private fun LadderControls(state: SellWizardViewModel.UiState, vm: SellWizardVie LadderGenerator.AmountMode.EQUAL_CRYPTO to stringResource(R.string.sell_wizard_ladder_amount_equal_crypto), LadderGenerator.AmountMode.EQUAL_FIAT to stringResource(R.string.sell_wizard_ladder_amount_equal_fiat) ).forEach { (mode, label) -> - AssistChip( - onClick = { vm.setLadderAmountMode(mode) }, - label = { Text(label) }, - colors = if (state.ladderAmountMode == mode) { - androidx.compose.material3.AssistChipDefaults.assistChipColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - labelColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - } else androidx.compose.material3.AssistChipDefaults.assistChipColors() + com.accbot.dca.presentation.components.SelectableChip( + text = label, + selected = state.ladderAmountMode == mode, + onClick = { vm.setLadderAmountMode(mode) } ) } } @@ -848,21 +868,22 @@ private fun LadderPreviewTable( } /** - * Inserts a thin space (' ') as a thousand separator in the integer part of a numeric - * input. The decimal part (after '.' or ',') is left as-is. The user keeps typing raw - * digits; only the visual presentation is grouped. + * Locale-aware thousand-separator visual transformation. The user keeps typing raw digits + * (with '.' or ',' for decimal); only the visual presentation is grouped using the active + * locale's grouping separator (e.g. CS uses non-breaking space, EN uses ','). */ private val ThousandSeparator: VisualTransformation = VisualTransformation { text -> val raw = text.text if (raw.isEmpty()) return@VisualTransformation TransformedText(text, OffsetMapping.Identity) - val transformed = formatThousands(raw) + val groupChar = java.text.DecimalFormatSymbols.getInstance(java.util.Locale.getDefault()).groupingSeparator + val transformed = formatThousands(raw, groupChar) TransformedText( AnnotatedString(transformed), object : OffsetMapping { override fun originalToTransformed(offset: Int): Int { if (offset <= 0) return 0 val capped = offset.coerceAtMost(raw.length) - return formatThousands(raw.substring(0, capped)).length + return formatThousands(raw.substring(0, capped), groupChar).length } override fun transformedToOriginal(offset: Int): Int { @@ -873,7 +894,7 @@ private val ThousandSeparator: VisualTransformation = VisualTransformation { tex for (ch in transformed) { if (seen >= offset) break seen++ - if (ch != ' ') orig++ + if (ch != groupChar) orig++ } return orig.coerceAtMost(raw.length) } @@ -881,12 +902,12 @@ private val ThousandSeparator: VisualTransformation = VisualTransformation { tex ) } -private fun formatThousands(s: String): String { +private fun formatThousands(s: String, groupChar: Char): String { val dotIdx = s.indexOfAny(charArrayOf('.', ',')) val intPart = if (dotIdx >= 0) s.substring(0, dotIdx) else s val rest = if (dotIdx >= 0) s.substring(dotIdx) else "" val grouped = if (intPart.length <= 3) intPart - else intPart.reversed().chunked(3).joinToString(" ").reversed() + else intPart.reversed().chunked(3).joinToString(groupChar.toString()).reversed() return grouped + rest } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt index f39cb8e..a4b7aa6 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt @@ -1,8 +1,10 @@ package com.accbot.dca.presentation.screens.plans.sell +import android.content.Context import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.accbot.dca.R import com.accbot.dca.data.local.CredentialsStore import com.accbot.dca.data.local.DcaDatabase import com.accbot.dca.data.local.UserPreferences @@ -17,7 +19,9 @@ import com.accbot.dca.domain.usecase.SellValidation import com.accbot.dca.domain.usecase.ValidateSellOrderUseCase import com.accbot.dca.exchange.ExchangeApiFactory import com.accbot.dca.exchange.MinOrderSizeRepository +import com.accbot.dca.presentation.utils.NumberFormatters import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -29,6 +33,16 @@ import java.math.BigDecimal import java.math.RoundingMode import javax.inject.Inject +/** Locale-free ladder validation error; UI resolves to a string resource. */ +sealed class LadderError { + object AvgRequired : LadderError() + object AmountMustBePositive : LadderError() + object CountMin2 : LadderError() + object ToMustExceedFrom : LadderError() + object Insufficient : LadderError() + data class BelowMin(val smallest: BigDecimal, val min: BigDecimal, val fiat: String) : LadderError() +} + /** * State + actions for the two-step limit-sell wizard (input -> confirm -> submit). * @@ -42,6 +56,7 @@ import javax.inject.Inject */ @HiltViewModel class SellWizardViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val validateSellOrderUseCase: ValidateSellOrderUseCase, private val placeLimitSellUseCase: PlaceLimitSellUseCase, private val placeLadderSellUseCase: PlaceLadderSellUseCase, @@ -93,7 +108,7 @@ class SellWizardViewModel @Inject constructor( val ladderCountInput: String = "5", val ladderAmountMode: LadderGenerator.AmountMode = LadderGenerator.AmountMode.EQUAL_CRYPTO, val ladderPreview: List = emptyList(), - val ladderHardError: String? = null, + val ladderHardError: LadderError? = null, val ladderOutcome: LadderSubmitOutcome? = null, val step: Step = Step.INPUT, val initializing: Boolean = true, @@ -451,7 +466,7 @@ class SellWizardViewModel @Inject constructor( LadderRangeMode.PROFIT_PCT -> { if (avg == null) { _uiState.update { - it.copy(ladderPreview = emptyList(), ladderHardError = "Pro profit % musí být zadaná průměrná nákupní cena") + it.copy(ladderPreview = emptyList(), ladderHardError = LadderError.AvgRequired) } return } @@ -468,26 +483,30 @@ class SellWizardViewModel @Inject constructor( return } if (total <= BigDecimal.ZERO) { - _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = "Množství musí být větší než 0") } + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = LadderError.AmountMustBePositive) } return } if (count < 2) { - _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = "Počet orderů musí být alespoň 2") } + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = LadderError.CountMin2) } return } if (to <= from || from <= BigDecimal.ZERO) { - _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = "Do musí být větší než Od (oba kladné)") } + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = LadderError.ToMustExceedFrom) } return } if (total > st.availableToSell) { - _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = "Nemáš tolik k dispozici") } + _uiState.update { it.copy(ladderPreview = emptyList(), ladderHardError = LadderError.Insufficient) } return } val preview = LadderGenerator.generate(total, from, to, count, st.ladderAmountMode) val minOrderFiatValue = preview.minOf { it.cryptoAmount * it.limitPrice } - val hardError = if (st.minOrderFiat > BigDecimal.ZERO && minOrderFiatValue < st.minOrderFiat) { - "Hodnota nejmenšího orderu (${minOrderFiatValue.setScale(2, RoundingMode.HALF_UP)} ${st.fiat}) je pod minimem ${st.minOrderFiat} ${st.fiat}" + val hardError: LadderError? = if (st.minOrderFiat > BigDecimal.ZERO && minOrderFiatValue < st.minOrderFiat) { + LadderError.BelowMin( + smallest = minOrderFiatValue.setScale(2, RoundingMode.HALF_UP), + min = st.minOrderFiat, + fiat = st.fiat + ) } else null val totalNet = preview.fold(BigDecimal.ZERO) { acc, o -> @@ -574,7 +593,8 @@ class SellWizardViewModel @Inject constructor( _uiState.update { it.copy( submitting = false, - submitError = result.exceptionOrNull()?.message ?: "Neznámá chyba" + submitError = result.exceptionOrNull()?.message + ?: context.getString(R.string.sell_wizard_submit_error_unknown) ) } } diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 7f46382..56e7271 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -1041,4 +1041,16 @@ Zrušit vše Zrušit všechny příkazy? Opravdu zrušit všechny otevřené prodejní příkazy (%1$d)? Akci nelze vrátit. + Množství musí být větší než 0 + Limitní cena musí být větší než 0 + Minimální hodnota orderu je %1$s (zvyš množství nebo cenu) + Nemáš tolik k dispozici (k dispozici %1$s) + Pro profit %% musí být zadaná průměrná nákupní cena + Množství musí být větší než 0 + Počet orderů musí být alespoň 2 + Do musí být větší než Od (oba kladné) + Nemáš tolik k dispozici + Hodnota nejmenšího orderu (%1$s %2$s) je pod minimem %3$s %2$s + = %1$s %% z dostupných + Neznámá chyba diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 28a44f3..127b985 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -1035,4 +1035,16 @@ Cancel all Cancel all orders? Cancel all %1$d open sell orders? This cannot be undone. + Amount must be greater than 0 + Limit price must be greater than 0 + Minimum order value is %1$s (raise amount or price) + Not enough available (available %1$s) + Profit %% requires the average buy price to be set + Amount must be greater than 0 + Order count must be at least 2 + To must be greater than From (both positive) + Not enough available + Smallest order (%1$s %2$s) is below the minimum %3$s %2$s + = %1$s %% of available + Unknown error From 4b4bc17fdb5ca3f7f58a63dc5e213baeee406d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 10 May 2026 17:23:25 +0200 Subject: [PATCH 65/75] refactor(sell): split SellInputStep, derive SellSummary in VM - New SellSummary data class on UiState computes proceeds, fee, net, costBasis, profit, profitPct, amountPct, totalProgress, targetProgressPct in one place. UI reads state.summary instead of reparsing on each render. - SellInputStep (385 lines) split into 8 focused composables: SellWizardHeader, SellInfoBlock, AvgBuyField, AmountField, LadderModeRow, PriceField, NetField, OrderSummary, LossBannerSection, ValidationsList. - Drop Double from progress percentage (was Double-divide for display); targetProgressPct is now an Int computed on BigDecimal. - Collapse 3 identical priceAvgPlus AssistChips into a single forEach. --- .../plans/sell/SellWizardBottomSheet.kt | 675 +++++++++--------- .../screens/plans/sell/SellWizardViewModel.kt | 56 ++ 2 files changed, 400 insertions(+), 331 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt index 0d02d29..d0ff91f 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardBottomSheet.kt @@ -166,6 +166,13 @@ private fun SellInputStep( vm: SellWizardViewModel, onDismiss: () -> Unit ) { + val amountError = state.validations.filterIsInstance() + .firstOrNull { it.field == SellValidation.Field.AMOUNT } + val priceError = state.validations.filterIsInstance() + .firstOrNull { it.field == SellValidation.Field.PRICE } + val netError = state.validations.filterIsInstance() + .firstOrNull { it.field == SellValidation.Field.NET } + Column( modifier = Modifier .fillMaxWidth() @@ -173,376 +180,382 @@ private fun SellInputStep( .padding(horizontal = 16.dp) .verticalScroll(rememberScrollState()) ) { - // Header - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onDismiss) { - Icon(Icons.Default.Close, contentDescription = null) - } - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.sell_wizard_title, state.crypto, state.fiat), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - Text( - text = state.exchangeName, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - + SellWizardHeader(state, onDismiss) Spacer(Modifier.height(12.dp)) - - // Info block (read-only context) - InfoRow( - stringResource(R.string.sell_wizard_spot_price), - state.spotPrice?.let { "${NumberFormatters.fiat(it)} ${state.fiat}" } ?: "-" - ) - InfoRow( - stringResource(R.string.sell_wizard_available), - "${NumberFormatters.crypto(state.availableToSell)} ${state.crypto}" - ) - - if (state.inventoryDeficit > BigDecimal.ZERO) { - Spacer(Modifier.height(4.dp)) - WarningBanner( - stringResource( - R.string.sell_wizard_inventory_deficit, - "${NumberFormatters.crypto(state.inventoryDeficit)} ${state.crypto}" - ) - ) - } - - // Editable avg buy price + SellInfoBlock(state) Spacer(Modifier.height(12.dp)) - Text( - stringResource(R.string.sell_wizard_avg_buy), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold - ) - Spacer(Modifier.height(4.dp)) - OutlinedTextField( - value = state.avgBuyPriceInput, - onValueChange = vm::setAvgBuyPrice, - visualTransformation = ThousandSeparator, - trailingIcon = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (state.avgBuyPriceManual && state.avgBuyPriceAuto != null) { - AssistChip( - onClick = vm::resetAvgBuyPrice, - label = { Text(stringResource(R.string.sell_wizard_avg_buy_reset)) }, - modifier = Modifier.padding(end = 8.dp) - ) - } - Text( - text = state.fiat, - modifier = Modifier.padding(horizontal = 12.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - supportingText = { - Text( - stringResource( - when { - state.avgBuyPriceAuto == null && state.avgBuyPriceInput.isBlank() -> - R.string.sell_wizard_avg_buy_helper_required - state.avgBuyPriceManual -> - R.string.sell_wizard_avg_buy_helper_manual - else -> R.string.sell_wizard_avg_buy_helper_auto - } - ) - ) - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - - // Field-specific errors: shown as supportingText under the matching field. - val amountError = state.validations.filterIsInstance() - .firstOrNull { it.field == SellValidation.Field.AMOUNT } - val priceError = state.validations.filterIsInstance() - .firstOrNull { it.field == SellValidation.Field.PRICE } - val netError = state.validations.filterIsInstance() - .firstOrNull { it.field == SellValidation.Field.NET } - + AvgBuyField(state, vm) Spacer(Modifier.height(16.dp)) - Text( - stringResource(R.string.sell_wizard_amount), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold - ) - Spacer(Modifier.height(4.dp)) - val amountAsBd = state.amountInput.toBigDecimalOrNull() - val amountPct = amountAsBd?.let { a -> - if (state.availableToSell > BigDecimal.ZERO) { - a.divide(state.availableToSell, 4, RoundingMode.HALF_UP) - .multiply(BigDecimal(100)).setScale(1, RoundingMode.HALF_UP) - } else null - } - OutlinedTextField( - value = state.amountInput, - onValueChange = vm::setAmount, - isError = amountError != null, - supportingText = { - when { - amountError != null -> Text(amountError.localizedText(), color = MaterialTheme.colorScheme.error) - amountPct != null -> Text(stringResource(R.string.sell_wizard_amount_pct_hint, amountPct.toPlainString())) - } - }, - trailingIcon = { - Text( - text = state.crypto, - modifier = Modifier.padding(horizontal = 12.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - Row( - modifier = Modifier.padding(top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - val allLabel = stringResource(R.string.sell_wizard_amount_all) - listOf(25 to "25 %", 50 to "50 %", 75 to "75 %", 100 to allLabel).forEach { (pct, label) -> - AssistChip( - onClick = { vm.setAmountPct(pct) }, - label = { Text(label, maxLines = 1, softWrap = false) } - ) - } - } - - // Ladder mode toggle + AmountField(state, vm, amountError) Spacer(Modifier.height(12.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { vm.setLadderEnabled(!state.ladderEnabled) } - ) { - Checkbox( - checked = state.ladderEnabled, - onCheckedChange = vm::setLadderEnabled - ) - Text(stringResource(R.string.sell_wizard_ladder_enable)) - } - + LadderModeRow(state, vm) if (state.ladderEnabled) { LadderControls(state = state, vm = vm) + } else { + Spacer(Modifier.height(16.dp)) + PriceField(state, vm, priceError) } - + Spacer(Modifier.height(16.dp)) + NetField(state, vm, netError) if (!state.ladderEnabled) { + OrderSummary(state) + LossBannerSection(state) + } + Spacer(Modifier.height(8.dp)) + ValidationsList(state) Spacer(Modifier.height(16.dp)) - Text( - stringResource(R.string.sell_wizard_limit_price), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) - Spacer(Modifier.height(4.dp)) - OutlinedTextField( - value = state.priceInput, - onValueChange = vm::setPrice, - visualTransformation = ThousandSeparator, - isError = priceError != null, - supportingText = priceError?.let { err -> - { Text(err.localizedText(), color = MaterialTheme.colorScheme.error) } - }, - trailingIcon = { - Text( - text = state.fiat, - modifier = Modifier.padding(horizontal = 12.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - Row( - modifier = Modifier.padding(top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) + Button( + onClick = vm::proceedToConfirm, + enabled = state.canProceed, + modifier = Modifier.fillMaxWidth() ) { - AssistChip( - onClick = vm::setPriceSpot, - label = { Text(stringResource(R.string.sell_wizard_chip_spot), maxLines = 1, softWrap = false) }, - enabled = state.spotPrice != null - ) - AssistChip( - onClick = { vm.setPriceAvgPlus(10) }, - label = { Text("+10 %", maxLines = 1, softWrap = false) }, - enabled = state.avgBuyPrice != null - ) - AssistChip( - onClick = { vm.setPriceAvgPlus(25) }, - label = { Text("+25 %", maxLines = 1, softWrap = false) }, - enabled = state.avgBuyPrice != null + Text(stringResource(R.string.sell_wizard_proceed)) + } + Spacer(Modifier.height(32.dp)) + } +} + +@Composable +private fun SellWizardHeader(state: SellWizardViewModel.UiState, onDismiss: () -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = null) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.sell_wizard_title, state.crypto, state.fiat), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold ) - AssistChip( - onClick = { vm.setPriceAvgPlus(50) }, - label = { Text("+50 %", maxLines = 1, softWrap = false) }, - enabled = state.avgBuyPrice != null + Text( + text = state.exchangeName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } + } +} - } // end if !ladderEnabled (Limit price section only) - - // Net fiat field - editable in single mode, read-only display of ladder total in ladder mode. - Spacer(Modifier.height(16.dp)) - Text( - stringResource(R.string.sell_wizard_net_fiat), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold - ) +@Composable +private fun SellInfoBlock(state: SellWizardViewModel.UiState) { + InfoRow( + stringResource(R.string.sell_wizard_spot_price), + state.spotPrice?.let { "${NumberFormatters.fiat(it)} ${state.fiat}" } ?: "-" + ) + InfoRow( + stringResource(R.string.sell_wizard_available), + "${NumberFormatters.crypto(state.availableToSell)} ${state.crypto}" + ) + if (state.inventoryDeficit > BigDecimal.ZERO) { Spacer(Modifier.height(4.dp)) - OutlinedTextField( - value = state.netInput, - onValueChange = if (state.ladderEnabled) vm::setLadderNetTarget else vm::setNetFiat, - visualTransformation = ThousandSeparator, - isError = !state.ladderEnabled && netError != null, - supportingText = netError?.takeIf { !state.ladderEnabled }?.let { err -> - { Text(err.localizedText(), color = MaterialTheme.colorScheme.error) } - }, - trailingIcon = { + WarningBanner( + stringResource( + R.string.sell_wizard_inventory_deficit, + "${NumberFormatters.crypto(state.inventoryDeficit)} ${state.crypto}" + ) + ) + } +} + +@Composable +private fun AvgBuyField(state: SellWizardViewModel.UiState, vm: SellWizardViewModel) { + Text( + stringResource(R.string.sell_wizard_avg_buy), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(4.dp)) + OutlinedTextField( + value = state.avgBuyPriceInput, + onValueChange = vm::setAvgBuyPrice, + visualTransformation = ThousandSeparator, + trailingIcon = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (state.avgBuyPriceManual && state.avgBuyPriceAuto != null) { + AssistChip( + onClick = vm::resetAvgBuyPrice, + label = { Text(stringResource(R.string.sell_wizard_avg_buy_reset)) }, + modifier = Modifier.padding(end = 8.dp) + ) + } Text( text = state.fiat, modifier = Modifier.padding(horizontal = 12.dp), color = MaterialTheme.colorScheme.onSurfaceVariant ) - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - if (!state.ladderEnabled) { + } + }, + supportingText = { Text( - stringResource(R.string.sell_wizard_net_preset_label), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp) + stringResource( + when { + state.avgBuyPriceAuto == null && state.avgBuyPriceInput.isBlank() -> + R.string.sell_wizard_avg_buy_helper_required + state.avgBuyPriceManual -> + R.string.sell_wizard_avg_buy_helper_manual + else -> R.string.sell_wizard_avg_buy_helper_auto + } + ) ) - Row( - modifier = Modifier.padding(top = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - listOf(0.10 to "+10 %", 0.20 to "+20 %", 0.50 to "+50 %", 1.00 to "+100 %").forEach { (factor, label) -> - AssistChip( - onClick = { vm.applyNetProfitPreset(factor) }, - label = { Text(label, maxLines = 1, softWrap = false) }, - enabled = state.avgBuyPrice != null && state.amountInput.toBigDecimalOrNull() != null - ) - } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) +} + +@Composable +private fun AmountField( + state: SellWizardViewModel.UiState, + vm: SellWizardViewModel, + amountError: SellValidation.HardError? +) { + Text( + stringResource(R.string.sell_wizard_amount), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(4.dp)) + OutlinedTextField( + value = state.amountInput, + onValueChange = vm::setAmount, + isError = amountError != null, + supportingText = { + val pctText = state.summary?.amountPct + when { + amountError != null -> Text(amountError.localizedText(), color = MaterialTheme.colorScheme.error) + pctText != null -> Text(stringResource(R.string.sell_wizard_amount_pct_hint, pctText.toPlainString())) } + }, + trailingIcon = { + Text( + text = state.crypto, + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Row( + modifier = Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val allLabel = stringResource(R.string.sell_wizard_amount_all) + listOf(25 to "25 %", 50 to "50 %", 75 to "75 %", 100 to allLabel).forEach { (pct, label) -> + AssistChip( + onClick = { vm.setAmountPct(pct) }, + label = { Text(label, maxLines = 1, softWrap = false) } + ) } + } +} - val amountBD = state.amountInput.toBigDecimalOrNull() ?: BigDecimal.ZERO - val priceBD = state.priceInput.toBigDecimalOrNull() ?: BigDecimal.ZERO - val proceeds = (amountBD * priceBD).setScale(2, RoundingMode.HALF_UP) - val feeAmount = (proceeds * state.feeRate).setScale(2, RoundingMode.HALF_UP) - val netProceeds = (proceeds - feeAmount).setScale(2, RoundingMode.HALF_UP) +@Composable +private fun LadderModeRow(state: SellWizardViewModel.UiState, vm: SellWizardViewModel) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { vm.setLadderEnabled(!state.ladderEnabled) } + ) { + Checkbox( + checked = state.ladderEnabled, + onCheckedChange = vm::setLadderEnabled + ) + Text(stringResource(R.string.sell_wizard_ladder_enable)) + } +} - if (!state.ladderEnabled && proceeds > BigDecimal.ZERO) { - Spacer(Modifier.height(16.dp)) +@Composable +private fun PriceField( + state: SellWizardViewModel.UiState, + vm: SellWizardViewModel, + priceError: SellValidation.HardError? +) { + Text( + stringResource(R.string.sell_wizard_limit_price), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + OutlinedTextField( + value = state.priceInput, + onValueChange = vm::setPrice, + visualTransformation = ThousandSeparator, + isError = priceError != null, + supportingText = priceError?.let { err -> + { Text(err.localizedText(), color = MaterialTheme.colorScheme.error) } + }, + trailingIcon = { Text( - stringResource(R.string.sell_wizard_summary), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold + text = state.fiat, + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(Modifier.height(4.dp)) - InfoRow( - stringResource(R.string.sell_wizard_proceeds), - "${NumberFormatters.fiat(proceeds)} ${state.fiat}" + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Row( + modifier = Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + AssistChip( + onClick = vm::setPriceSpot, + label = { Text(stringResource(R.string.sell_wizard_chip_spot), maxLines = 1, softWrap = false) }, + enabled = state.spotPrice != null + ) + listOf(10, 25, 50).forEach { pct -> + AssistChip( + onClick = { vm.setPriceAvgPlus(pct) }, + label = { Text("+$pct %", maxLines = 1, softWrap = false) }, + enabled = state.avgBuyPrice != null ) - if (state.feeRate > BigDecimal.ZERO) { - InfoRow( - stringResource(R.string.sell_wizard_summary_fee), - "-${NumberFormatters.fiat(feeAmount)} ${state.fiat} (${state.feeRate.multiply(BigDecimal(100)).setScale(2, RoundingMode.HALF_UP).toPlainString()} %)" - ) - } - state.avgBuyPrice?.let { avg -> - val costBasis = amountBD * avg - val netProfit = (netProceeds - costBasis).setScale(2, RoundingMode.HALF_UP) - val netProfitPct = if (costBasis > BigDecimal.ZERO) { - netProfit.divide(costBasis, 4, RoundingMode.HALF_UP) - .multiply(BigDecimal(100)) - .setScale(1, RoundingMode.HALF_UP) - } else BigDecimal.ZERO - val sign = if (netProfit >= BigDecimal.ZERO) "+" else "" - InfoRow( - label = stringResource(R.string.sell_wizard_summary_net_profit), - value = "$sign${NumberFormatters.fiat(netProfit)} ${state.fiat} ($sign${netProfitPct.toPlainString()} %)", - color = when { - netProfit > BigDecimal.ZERO -> successColor() - netProfit < BigDecimal.ZERO -> Error - else -> null - } - ) - - state.targetProfitAmount?.takeIf { it > BigDecimal.ZERO }?.let { target -> - val totalProgress = state.realizedPnLSoFar + netProfit - val pct = (totalProgress.toDouble() / target.toDouble()).coerceAtLeast(0.0) - InfoRow( - stringResource(R.string.sell_wizard_summary_target_progress), - "${NumberFormatters.fiat(totalProgress)} / ${NumberFormatters.fiat(target)} ${state.fiat} (${"%.0f".format(pct * 100)} %)" - ) - } - } } + } +} - // Loss banner from validations (single mode only - ladder has aggregate via preview) - if (!state.ladderEnabled) { - state.validations.filterIsInstance().firstOrNull()?.let { loss -> - Spacer(Modifier.height(8.dp)) - val isPriceBelowAvg = state.priceInput.toBigDecimalOrNull()?.let { p -> - state.avgBuyPrice?.let { avg -> p < avg } - } ?: false - LossBanner( - stringResource( - if (isPriceBelowAvg) R.string.sell_wizard_loss_below_buy - else R.string.sell_wizard_loss_after_fee, - NumberFormatters.fiat(loss.lossFiat), - state.fiat - ) +@Composable +private fun NetField( + state: SellWizardViewModel.UiState, + vm: SellWizardViewModel, + netError: SellValidation.HardError? +) { + Text( + stringResource(R.string.sell_wizard_net_fiat), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(4.dp)) + OutlinedTextField( + value = state.netInput, + onValueChange = if (state.ladderEnabled) vm::setLadderNetTarget else vm::setNetFiat, + visualTransformation = ThousandSeparator, + isError = !state.ladderEnabled && netError != null, + supportingText = netError?.takeIf { !state.ladderEnabled }?.let { err -> + { Text(err.localizedText(), color = MaterialTheme.colorScheme.error) } + }, + trailingIcon = { + Text( + text = state.fiat, + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + if (!state.ladderEnabled) { + Text( + stringResource(R.string.sell_wizard_net_preset_label), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + Row( + modifier = Modifier.padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val presetEnabled = state.avgBuyPrice != null && state.amountInput.toBigDecimalOrNull() != null + listOf(0.10 to "+10 %", 0.20 to "+20 %", 0.50 to "+50 %", 1.00 to "+100 %").forEach { (factor, label) -> + AssistChip( + onClick = { vm.applyNetProfitPreset(factor) }, + label = { Text(label, maxLines = 1, softWrap = false) }, + enabled = presetEnabled ) } } + } +} - // Validations: field-tagged hard errors render under their field; only generic - // ones and warnings end up in this list. - Spacer(Modifier.height(8.dp)) - state.validations.forEach { v -> - when (v) { - is SellValidation.HardError -> if (v.field == SellValidation.Field.GENERIC) { - Text( - text = v.localizedText(), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(vertical = 4.dp) - ) - } - is SellValidation.InstantFillInfo -> InfoBanner( - stringResource(R.string.sell_wizard_instant_fill_warning, NumberFormatters.fiat(v.spot), state.fiat) - ) - is SellValidation.FarFromMarketWarning -> WarningBanner( - stringResource(R.string.sell_wizard_far_from_market_warning) - ) - is SellValidation.LossWarning -> { /* shown via LossBanner in summary section */ } +@Composable +private fun OrderSummary(state: SellWizardViewModel.UiState) { + val s = state.summary ?: return + Spacer(Modifier.height(16.dp)) + Text( + stringResource(R.string.sell_wizard_summary), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(4.dp)) + InfoRow( + stringResource(R.string.sell_wizard_proceeds), + "${NumberFormatters.fiat(s.proceeds)} ${state.fiat}" + ) + if (state.feeRate > BigDecimal.ZERO) { + val feePct = state.feeRate.multiply(BigDecimal(100)) + .setScale(2, RoundingMode.HALF_UP).toPlainString() + InfoRow( + stringResource(R.string.sell_wizard_summary_fee), + "-${NumberFormatters.fiat(s.feeAmount)} ${state.fiat} ($feePct %)" + ) + } + if (s.netProfit != null && s.netProfitPct != null) { + val sign = if (s.netProfit >= BigDecimal.ZERO) "+" else "" + InfoRow( + label = stringResource(R.string.sell_wizard_summary_net_profit), + value = "$sign${NumberFormatters.fiat(s.netProfit)} ${state.fiat} ($sign${s.netProfitPct.toPlainString()} %)", + color = when { + s.netProfit > BigDecimal.ZERO -> successColor() + s.netProfit < BigDecimal.ZERO -> Error + else -> null } + ) + val target = state.targetProfitAmount + if (target != null && target > BigDecimal.ZERO && s.totalProgress != null && s.targetProgressPct != null) { + InfoRow( + stringResource(R.string.sell_wizard_summary_target_progress), + "${NumberFormatters.fiat(s.totalProgress)} / ${NumberFormatters.fiat(target)} ${state.fiat} (${s.targetProgressPct} %)" + ) } + } +} - Spacer(Modifier.height(16.dp)) - Button( - onClick = vm::proceedToConfirm, - enabled = state.canProceed, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.sell_wizard_proceed)) - } +@Composable +private fun LossBannerSection(state: SellWizardViewModel.UiState) { + val loss = state.validations.filterIsInstance().firstOrNull() ?: return + Spacer(Modifier.height(8.dp)) + val isPriceBelowAvg = state.priceInput.toBigDecimalOrNull()?.let { p -> + state.avgBuyPrice?.let { avg -> p < avg } + } ?: false + LossBanner( + stringResource( + if (isPriceBelowAvg) R.string.sell_wizard_loss_below_buy + else R.string.sell_wizard_loss_after_fee, + NumberFormatters.fiat(loss.lossFiat), + state.fiat + ) + ) +} - Spacer(Modifier.height(32.dp)) +/** + * Generic-tagged hard errors render here; field-tagged errors live under their input. + * LossWarning is rendered by [LossBannerSection], not in the generic list. + */ +@Composable +private fun ValidationsList(state: SellWizardViewModel.UiState) { + state.validations.forEach { v -> + when (v) { + is SellValidation.HardError -> if (v.field == SellValidation.Field.GENERIC) { + Text( + text = v.localizedText(), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + is SellValidation.InstantFillInfo -> InfoBanner( + stringResource(R.string.sell_wizard_instant_fill_warning, NumberFormatters.fiat(v.spot), state.fiat) + ) + is SellValidation.FarFromMarketWarning -> WarningBanner( + stringResource(R.string.sell_wizard_far_from_market_warning) + ) + is SellValidation.LossWarning -> { /* shown via LossBanner in summary section */ } + } } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt index a4b7aa6..87f8ab4 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/sell/SellWizardViewModel.kt @@ -43,6 +43,22 @@ sealed class LadderError { data class BelowMin(val smallest: BigDecimal, val min: BigDecimal, val fiat: String) : LadderError() } +/** + * Derived single-order summary: gross proceeds, fees, net, optional profit vs avg buy + * and amount-as-pct-of-available. Computed in the VM so the UI stays dumb. + */ +data class SellSummary( + val proceeds: BigDecimal, + val feeAmount: BigDecimal, + val netProceeds: BigDecimal, + val costBasis: BigDecimal?, + val netProfit: BigDecimal?, + val netProfitPct: BigDecimal?, + val amountPct: BigDecimal?, + val totalProgress: BigDecimal?, + val targetProgressPct: Int? +) + /** * State + actions for the two-step limit-sell wizard (input -> confirm -> submit). * @@ -131,6 +147,46 @@ class SellWizardViewModel @Inject constructor( amountInput.toBigDecimalOrNull() != null && priceInput.toBigDecimalOrNull() != null } + + /** + * Single-order summary derived from amount/price/avg/fee. null when amount or + * price is missing/zero - the UI hides the summary card in that case. + */ + val summary: SellSummary? + get() { + val a = amountInput.toBigDecimalOrNull() ?: return null + val p = priceInput.toBigDecimalOrNull() ?: return null + if (a <= BigDecimal.ZERO || p <= BigDecimal.ZERO) return null + val proceeds = (a * p).setScale(2, RoundingMode.HALF_UP) + val feeAmount = (proceeds * feeRate).setScale(2, RoundingMode.HALF_UP) + val netProceeds = (proceeds - feeAmount).setScale(2, RoundingMode.HALF_UP) + val avg = avgBuyPrice + val costBasis = avg?.let { a * it } + val netProfit = costBasis?.let { (netProceeds - it).setScale(2, RoundingMode.HALF_UP) } + val netProfitPct = if (costBasis != null && netProfit != null && costBasis > BigDecimal.ZERO) { + netProfit.divide(costBasis, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + .setScale(1, RoundingMode.HALF_UP) + } else null + val amountPct = if (availableToSell > BigDecimal.ZERO) { + a.divide(availableToSell, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + .setScale(1, RoundingMode.HALF_UP) + } else null + val totalProgress = netProfit?.let { realizedPnLSoFar + it } + val targetProgressPct = if (totalProgress != null && + targetProfitAmount != null && targetProfitAmount > BigDecimal.ZERO + ) { + val raw = totalProgress.divide(targetProfitAmount, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + raw.toInt().coerceAtLeast(0) + } else null + return SellSummary( + proceeds, feeAmount, netProceeds, + costBasis, netProfit, netProfitPct, + amountPct, totalProgress, targetProgressPct + ) + } } private val _uiState = MutableStateFlow(UiState()) From efb2e364592eaf597156cda2e5ab68de7fb6d642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 10 May 2026 18:20:47 +0200 Subject: [PATCH 66/75] feat(sell): notification on fill + chart trade markers Notifications: - New NotificationType.SELL_FILLED + NotificationTemplateArgs.SellFilled. - ResolvePendingTransactionsUseCase fires showSellFilledNotification when a SELL transitions to COMPLETED in this poll cycle. Per-transaction ID so multiple ladder fills produce separate notifications. - Localized title/text in EN+CS (TrendingUp icon, success color). Chart trade markers (Task 26 - was a stub): - New Daos.getCompletedSellsOrdered() (BUY-only chart series invariant preserved by keeping completedTransactions BUY-only and adding a separate completedSells cache). - PortfolioViewModel computes ChartTradeMarker list per page in computeChartData; pushed via UiState.tradeMarkers. - New private TradeMarkersDecoration in ChartComponents.kt: implements Vico Decoration, draws BUY up-triangles (green, bottom of plot) and SELL down-triangles (red, top of plot) at each trade's chart-bucket index. Pixel x = layerBounds.left + startPadding + (modelDelta * xSpacing) - scroll. - Markers shown only in FIAT denomination (CRYPTO mode hides them). - key() bumped to invalidate chart when markers change. --- .../java/com/accbot/dca/data/local/Daos.kt | 13 +++ .../com/accbot/dca/data/local/Entities.kt | 2 +- .../data/local/NotificationTemplateArgs.kt | 26 +++++ .../ResolvePendingTransactionsUseCase.kt | 32 +++++- .../components/ChartComponents.kt | 99 ++++++++++++++++--- .../notifications/NotificationRenderer.kt | 13 +++ .../notifications/NotificationsScreen.kt | 2 + .../screens/portfolio/PortfolioScreen.kt | 2 + .../screens/portfolio/PortfolioViewModel.kt | 40 +++++++- .../accbot/dca/service/NotificationService.kt | 51 ++++++++++ .../app/src/main/res/values-cs/strings.xml | 2 + .../app/src/main/res/values/strings.xml | 2 + 12 files changed, 266 insertions(+), 18 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt index 5c3b435..06501e9 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt @@ -405,6 +405,19 @@ interface TransactionDao { """) suspend fun getCompletedTransactionsOrdered(exchange: String? = null): List + /** + * Completed SELL transactions, ordered by execution. Used to draw chart timeline + * markers; kept separate from [getCompletedTransactionsOrdered] so the BUY-only + * invariant of the chart series pipeline isn't disturbed. + */ + @Query(""" + SELECT * FROM transactions + WHERE status = 'COMPLETED' AND side = 'SELL' + AND (:exchange IS NULL OR exchange = :exchange) + ORDER BY executedAt ASC + """) + suspend fun getCompletedSellsOrdered(exchange: String? = null): List + @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE exchange = :exchange AND crypto = :crypto AND status = 'COMPLETED' AND side = 'BUY'") suspend fun getTotalCryptoByExchangeAndCrypto(exchange: String, crypto: String): String diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt index 5bffabb..4cc7e28 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt @@ -19,7 +19,7 @@ import java.time.Instant /** * Notification type for in-app notification history */ -enum class NotificationType { PURCHASE, ERROR, LOW_BALANCE, WITHDRAWAL_THRESHOLD, NETWORK_RETRY, MISSED_PURCHASES } +enum class NotificationType { PURCHASE, ERROR, LOW_BALANCE, WITHDRAWAL_THRESHOLD, NETWORK_RETRY, MISSED_PURCHASES, SELL_FILLED } /** * Room type converters diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt index 973b844..5771009 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt @@ -177,6 +177,24 @@ sealed class NotificationTemplateArgs { }.toString() } + /** Limit sell order filled (PENDING/PARTIAL -> COMPLETED). */ + data class SellFilled( + val cryptoAmount: String, + val crypto: String, + val fiatAmount: String, + val fiat: String, + val price: String + ) : NotificationTemplateArgs() { + override fun toJson(): String = JSONObject().apply { + put(KEY_TYPE, TYPE_SELL_FILLED) + put("cryptoAmount", cryptoAmount) + put("crypto", crypto) + put("fiatAmount", fiatAmount) + put("fiat", fiat) + put("price", price) + }.toString() + } + companion object { private const val KEY_TYPE = "type" private const val TYPE_PURCHASE = "purchase" @@ -189,6 +207,7 @@ sealed class NotificationTemplateArgs { private const val TYPE_NETWORK_RETRY = "network_retry" private const val TYPE_MISSED_PURCHASES = "missed_purchases" private const val TYPE_MISSING_CREDENTIALS = "missing_credentials" + private const val TYPE_SELL_FILLED = "sell_filled" fun fromJson(json: String): NotificationTemplateArgs? = try { val obj = JSONObject(json) @@ -253,6 +272,13 @@ sealed class NotificationTemplateArgs { exchangeName = obj.getString("exchangeName"), connectionName = obj.optString("connectionName", "") ) + TYPE_SELL_FILLED -> SellFilled( + cryptoAmount = obj.getString("cryptoAmount"), + crypto = obj.getString("crypto"), + fiatAmount = obj.getString("fiatAmount"), + fiat = obj.getString("fiat"), + price = obj.getString("price") + ) else -> null } } catch (_: Exception) { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt index facccba..b9424ad 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt @@ -4,7 +4,10 @@ import android.util.Log import com.accbot.dca.data.local.CredentialsStore import com.accbot.dca.data.local.DcaDatabase import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.domain.model.TransactionSide +import com.accbot.dca.domain.model.TransactionStatus import com.accbot.dca.exchange.ExchangeApiFactory +import com.accbot.dca.service.NotificationService import javax.inject.Inject /** @@ -17,12 +20,15 @@ import javax.inject.Inject * * Uses the guarded [TransactionDao.updateResolvedTransaction] UPDATE so a concurrent * user cancel (which sets status=FAILED) is never clobbered. + * + * Fires a system notification for each SELL that transitions to COMPLETED in this run. */ class ResolvePendingTransactionsUseCase @Inject constructor( private val database: DcaDatabase, private val credentialsStore: CredentialsStore, private val exchangeApiFactory: ExchangeApiFactory, - private val userPreferences: UserPreferences + private val userPreferences: UserPreferences, + private val notificationService: NotificationService ) { suspend operator fun invoke(): Int { val pendingTransactions = database.transactionDao().getResolvablePendingTransactions() @@ -44,15 +50,35 @@ class ResolvePendingTransactionsUseCase @Inject constructor( val result = api.getOrderStatus(orderId, tx.crypto, tx.fiat) ?: continue + val newPrice = result.avgFillPrice ?: tx.price val rows = database.transactionDao().updateResolvedTransaction( id = tx.id, newStatus = result.status, cryptoAmount = result.filledCryptoAmount, fiatAmount = result.filledFiatAmount, - price = result.avgFillPrice ?: tx.price, + price = newPrice, fee = result.fee ?: tx.fee ) - if (rows > 0) resolvedCount++ + if (rows > 0) { + resolvedCount++ + if (tx.side == TransactionSide.SELL && result.status == TransactionStatus.COMPLETED) { + try { + notificationService.showSellFilledNotification( + crypto = tx.crypto, + cryptoAmount = result.filledCryptoAmount, + fiatAmount = result.filledFiatAmount, + fiat = tx.fiat, + price = newPrice, + transactionId = tx.id, + planId = tx.planId ?: 0, + exchange = tx.exchange, + connectionId = tx.connectionId + ) + } catch (e: Exception) { + Log.w(TAG, "Sell-filled notification failed for tx ${tx.id}", e) + } + } + } } catch (e: Exception) { Log.w(TAG, "Failed to resolve pending transaction ${tx.id}", e) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt index bbda776..97b18c7 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt @@ -62,12 +62,15 @@ import com.patrykandpatrick.vico.compose.cartesian.marker.rememberShowOnPress import com.patrykandpatrick.vico.compose.common.component.rememberShapeComponent import com.patrykandpatrick.vico.compose.common.component.rememberLineComponent import com.patrykandpatrick.vico.core.cartesian.data.CartesianLayerRangeProvider +import com.patrykandpatrick.vico.core.cartesian.decoration.Decoration import com.patrykandpatrick.vico.core.cartesian.decoration.HorizontalLine import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerController import com.patrykandpatrick.vico.core.common.data.ExtraStore import com.patrykandpatrick.vico.core.common.shape.CorneredShape import com.patrykandpatrick.vico.core.common.shape.DashedShape import com.patrykandpatrick.vico.core.common.shape.Shape +import androidx.compose.ui.graphics.toArgb +import java.time.ZoneId private val chartAccentColor = Primary private val costBasisColor = Color(0xFF888888) @@ -232,19 +235,65 @@ private fun LegendItem(color: Color, label: String, enabled: Boolean = true, onC /** * BUY/SELL transaction marker for the portfolio chart timeline. * - * NOTE (Task 26 / Phase 8): the parameter is currently consumed by the Vico chart - * via a no-op overlay. Drawing per-transaction triangles on top of a Vico - * CartesianChartHost requires custom decoration components and an x-coordinate - * solver that maps Instant -> chart pixel space; both are non-trivial because - * the chart's x-axis is index-based (epochDay buckets) rather than time-based. - * The API is wired up so callers can pass markers; visual rendering is a - * follow-up. See DCA Sell Extension plan, Task 26 (DONE_WITH_CONCERNS). + * Rendered by [TradeMarkersDecoration] as a small triangle at the bottom (BUY, + * up-triangle, success color) or top (SELL, down-triangle, error color) of the + * plot area, x-aligned with the chart bucket that contains the trade. */ data class ChartTradeMarker( val time: Instant, val side: TransactionSide ) +/** + * Custom Vico [Decoration] that renders BUY/SELL trade markers as triangles + * pinned to the plot area edges, aligned with chart-bucket indices. + * + * Coordinate math: each [ChartDataPoint] is at integer model x = its array + * index, so a marker for chart index i lives at model x = i.toDouble(). The + * pixel x is derived from layerBounds + layerDimensions.startPadding plus the + * scaled offset relative to ranges.minX, minus the current scroll. + */ +private class TradeMarkersDecoration( + private val markers: List>, + private val buyColorArgb: Int, + private val sellColorArgb: Int, + private val sizeDp: Float = 8f +) : Decoration { + + private val paint = android.graphics.Paint().apply { + isAntiAlias = true + style = android.graphics.Paint.Style.FILL + } + + override fun drawOverLayers(context: com.patrykandpatrick.vico.core.cartesian.CartesianDrawingContext) { + if (markers.isEmpty()) return + val bounds = context.layerBounds + val dims = context.layerDimensions + val ranges = context.ranges + val sizePx = context.dpToPx(sizeDp) + val halfSize = sizePx / 2f + val scroll = context.scroll + + for ((x, side) in markers) { + val modelDelta = x - ranges.minX + val pxX = bounds.left + dims.startPadding + (modelDelta * dims.xSpacing).toFloat() - scroll + if (pxX < bounds.left - halfSize || pxX > bounds.right + halfSize) continue + val (color, apexY, baseY) = when (side) { + TransactionSide.BUY -> Triple(buyColorArgb, bounds.bottom - sizePx, bounds.bottom) + TransactionSide.SELL -> Triple(sellColorArgb, bounds.top + sizePx, bounds.top) + } + paint.color = color + val path = android.graphics.Path().apply { + moveTo(pxX, apexY) + lineTo(pxX - halfSize, baseY) + lineTo(pxX + halfSize, baseY) + close() + } + context.canvas.drawPath(path, paint) + } + } +} + /** * Portfolio line chart with dual Y-axis support. * Left axis (start): portfolio value, cost basis, crypto price (all in fiat). @@ -267,9 +316,10 @@ fun PortfolioLineChart( onScrub: (Int?) -> Unit = {}, /** * Optional BUY (green up-triangle) / SELL (red down-triangle) markers to render - * on the chart timeline. Currently a stub – see [ChartTradeMarker] kdoc. + * on the chart timeline. Markers are bucketed to the chart point whose epochDay + * is the floor of the trade's epochDay. */ - @Suppress("UNUSED_PARAMETER") tradeMarkers: List = emptyList(), + tradeMarkers: List = emptyList(), /** * Limit prices of currently open (PENDING / PARTIAL) sell orders for the * displayed plan. Each value yields a horizontal line on the left (fiat) axis @@ -573,6 +623,33 @@ fun PortfolioLineChart( } } + // Trade markers: convert each ChartTradeMarker into a chart-x model index by + // finding the chart bucket whose epochDay floors the trade's epochDay. + val buyMarkerColor = accumulatedCryptoColor.toArgb() + val sellMarkerColor = MaterialTheme.colorScheme.error.toArgb() + val tradeMarkerPoints = remember(tradeMarkers, chartData) { + if (tradeMarkers.isEmpty() || chartData.isEmpty()) emptyList() + else { + val zone = ZoneId.systemDefault() + val firstDay = chartData.first().epochDay + val lastDay = chartData.last().epochDay + tradeMarkers.mapNotNull { m -> + val txDay = m.time.atZone(zone).toLocalDate().toEpochDay() + if (txDay < firstDay || txDay > lastDay) return@mapNotNull null + // Floor: largest index whose epochDay <= txDay. + val idx = chartData.indexOfLast { it.epochDay <= txDay } + if (idx < 0) null else idx.toDouble() to m.side + } + } + } + val tradeMarkerDecoration = remember(tradeMarkerPoints, buyMarkerColor, sellMarkerColor) { + if (tradeMarkerPoints.isEmpty()) null + else TradeMarkersDecoration(tradeMarkerPoints, buyMarkerColor, sellMarkerColor) + } + val allDecorations = remember(sellDecorations, tradeMarkerDecoration) { + sellDecorations + listOfNotNull(tradeMarkerDecoration) + } + // Y-axis range provider: when there are open-sell limit lines, expand the // auto-calculated Y range (left/fiat axis) so all limit prices remain visible // even when they sit far above/below the actual portfolio/price series. @@ -649,7 +726,7 @@ fun PortfolioLineChart( } } ) { - key(openSellLimitPrices) { + key(openSellLimitPrices, tradeMarkerPoints) { CartesianChartHost( chart = rememberCartesianChart( rememberLineCartesianLayer( @@ -688,7 +765,7 @@ fun PortfolioLineChart( ), marker = marker, markerController = CartesianMarkerController.rememberShowOnPress(), - decorations = sellDecorations + decorations = allDecorations ), modelProducer = modelProducer, scrollState = rememberVicoScrollState(scrollEnabled = false), diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt index f3765f5..3588aaf 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt @@ -146,6 +146,19 @@ object NotificationRenderer { ) title to message } + + is NotificationTemplateArgs.SellFilled -> { + val title = context.getString(R.string.notification_sell_filled_title) + val message = context.getString( + R.string.notification_sell_filled_text, + NumberFormatters.crypto(BigDecimal(args.cryptoAmount)), + args.crypto, + NumberFormatters.fiat(BigDecimal(args.fiatAmount)), + args.fiat, + NumberFormatters.fiat(BigDecimal(args.price)) + ) + title to message + } } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationsScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationsScreen.kt index 1621dc6..8a3385d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationsScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationsScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.CallMade +import androidx.compose.material.icons.automirrored.filled.TrendingUp import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -281,6 +282,7 @@ private fun NotificationTypeIcon(type: NotificationType) { NotificationType.WITHDRAWAL_THRESHOLD -> Icons.AutoMirrored.Filled.CallMade to Warning NotificationType.NETWORK_RETRY -> Icons.Default.WifiOff to Error NotificationType.MISSED_PURCHASES -> Icons.Default.EventBusy to Warning + NotificationType.SELL_FILLED -> Icons.AutoMirrored.Filled.TrendingUp to successCol } Box( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index 5326ea8..b88fd28 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -174,6 +174,8 @@ fun PortfolioScreen( openSellLimitPrices = if (uiState.currentPlanAllowsSells && uiState.denominationMode == DenominationMode.FIAT && uiState.limitLinesVisible) openSellLimitPrices else emptyList(), + tradeMarkers = if (uiState.denominationMode == DenominationMode.FIAT) + uiState.tradeMarkers else emptyList(), modifier = Modifier .fillMaxWidth() .weight(1f) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt index 50b0383..e93d151 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt @@ -108,7 +108,12 @@ data class PortfolioUiState( * `allowSells = true`. Drives visibility of the open-orders list and chart * horizontal lines on the per-plan page. */ - val currentPlanAllowsSells: Boolean = false + val currentPlanAllowsSells: Boolean = false, + /** + * Completed BUY/SELL transactions for the currently selected page, mapped to + * (executedAt, side). Drives the chart timeline triangle markers. + */ + val tradeMarkers: List = emptyList() ) @HiltViewModel @@ -161,6 +166,8 @@ class PortfolioViewModel @Inject constructor( private var openSellsJob: Job? = null private var completedTransactions: List = emptyList() + /** Cache of completed SELL transactions for chart-marker rendering only. */ + private var completedSells: List = emptyList() /** * Cached plan list used by [loadChartData] to build aggregate per-plan lines. * Refreshed by [loadPortfolio] and [refreshTransactionsAndPairs]. Without this @@ -207,6 +214,7 @@ class PortfolioViewModel @Inject constructor( // Use pre-filtered, sorted query (avoids loading failed/pending into memory) val completed = transactionDao.getCompletedTransactionsOrdered() completedTransactions = completed + completedSells = transactionDao.getCompletedSellsOrdered() // Load all plans (including disabled) for page building val allDbPlans = dcaPlanDao.getAllPlansOnceOrdered() @@ -282,6 +290,7 @@ class PortfolioViewModel @Inject constructor( try { val completed = transactionDao.getCompletedTransactionsOrdered() completedTransactions = completed + completedSells = transactionDao.getCompletedSellsOrdered() val allDbPlans = dcaPlanDao.getAllPlansOnceOrdered() cachedDbPlans = allDbPlans @@ -641,6 +650,7 @@ class PortfolioViewModel @Inject constructor( totalTransactions = chartResult.txCount, planLines = chartResult.planLines, cryptoGroupLines = chartResult.cryptoGroupLines, + tradeMarkers = chartResult.tradeMarkers, isChartLoading = false ) } @@ -697,7 +707,8 @@ class PortfolioViewModel @Inject constructor( val fiat: String?, val txCount: Int, val planLines: List, - val cryptoGroupLines: List + val cryptoGroupLines: List, + val tradeMarkers: List ) private suspend fun computeChartData(): ChartComputeResult { @@ -822,13 +833,36 @@ class PortfolioViewModel @Inject constructor( (fiat == null || tx.fiat == fiat) } + // Build trade markers from BUY (filteredTxs already pair/plan-scoped) + SELL. + val sellsForPage = if (planId != null) { + completedSells.filter { it.planId == planId } + } else { + completedSells.filter { (crypto == null || it.crypto == crypto) && (fiat == null || it.fiat == fiat) } + } + val buysForMarkers = filteredTxs.filter { + (crypto == null || it.crypto == crypto) && (fiat == null || it.fiat == fiat) + } + val markers = buildList { + buysForMarkers.forEach { add( + com.accbot.dca.presentation.components.ChartTradeMarker( + time = it.executedAt, side = com.accbot.dca.domain.model.TransactionSide.BUY + ) + ) } + sellsForPage.forEach { add( + com.accbot.dca.presentation.components.ChartTradeMarker( + time = it.executedAt, side = com.accbot.dca.domain.model.TransactionSide.SELL + ) + ) } + } + return ChartComputeResult( data = data, crypto = crypto, fiat = fiat, txCount = txCount, planLines = planLinesList, - cryptoGroupLines = cryptoGroupLinesList + cryptoGroupLines = cryptoGroupLinesList, + tradeMarkers = markers ) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt b/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt index dd66adb..34b1d4a 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt @@ -399,6 +399,56 @@ class NotificationService @Inject constructor( ) } + /** + * Show notification when a limit sell order is filled (PENDING/PARTIAL -> COMPLETED). + * Uses a unique ID per transaction so multiple ladder fills are all visible. + */ + suspend fun showSellFilledNotification( + crypto: String, + cryptoAmount: BigDecimal, + fiatAmount: BigDecimal, + fiat: String, + price: BigDecimal, + transactionId: Long = 0, + planId: Long = 0, + exchange: Exchange? = null, + connectionId: Long? = null + ) { + val args = NotificationTemplateArgs.SellFilled( + cryptoAmount = cryptoAmount.toPlainString(), + crypto = crypto, + fiatAmount = fiatAmount.toPlainString(), + fiat = fiat, + price = price.toPlainString() + ) + val (title, text) = NotificationRenderer.render(context, args) + val label = connectionLabel(connectionId, exchange) + val displayedTitle = if (!label.isNullOrBlank() && label != exchange?.displayName) { + "$label · $title" + } else title + + // Use transaction ID as the unique key so per-tier ladder fills don't collapse. + val keyForId = if (transactionId > 0) transactionId else planId + val sysNotifId = notificationIdForPlan(NOTIFICATION_ID_SELL_FILLED, keyForId) + persistAndShow( + sysNotifId = sysNotifId, + channel = CHANNEL_PURCHASE, + title = displayedTitle, + text = text, + entity = NotificationEntity( + type = NotificationType.SELL_FILLED, + title = displayedTitle, + message = text, + planId = planId.takeIf { it > 0 }, + crypto = crypto, + exchange = exchange, + connectionId = connectionId, + systemNotificationId = sysNotifId, + templateArgs = args.toJson() + ) + ) + } + /** * Cancel a specific system notification by its ID. */ @@ -466,6 +516,7 @@ class NotificationService @Inject constructor( private const val NOTIFICATION_ID_WITHDRAWAL_THRESHOLD = 40_000 private const val NOTIFICATION_ID_NETWORK_RETRY = 50_000 private const val NOTIFICATION_ID_MISSED_PURCHASES = 60_000 + private const val NOTIFICATION_ID_SELL_FILLED = 70_000 const val EXTRA_NOTIFICATION_ID = "extra_notification_id" diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 56e7271..fa34206 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -1053,4 +1053,6 @@ Hodnota nejmenšího orderu (%1$s %2$s) je pod minimem %3$s %2$s = %1$s %% z dostupných Neznámá chyba + Prodej proveden ✓ + Prodáno %1$s %2$s za %3$s %4$s @ %5$s %4$s diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 127b985..0cda724 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -1047,4 +1047,6 @@ Smallest order (%1$s %2$s) is below the minimum %3$s %2$s = %1$s %% of available Unknown error + Sell filled ✓ + Sold %1$s %2$s for %3$s %4$s @ %5$s %4$s From ae01f761e2006c7bc0026d1253ae2bb1e50a0a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 5 Jun 2026 09:47:57 +0200 Subject: [PATCH 67/75] test: bootstrap JVM unit-test harness (Robolectric + in-memory Room) No JVM unit tests existed before - only instrumentation/screenshot tests. Adds the test source set, Robolectric (pinned to SDK 34), in-memory Room builder, a configurable FakeExchangeApi, and a sanity test. Co-Authored-By: Claude Opus 4.8 (1M context) --- accbot-android/app/build.gradle.kts | 16 +++++ .../com/accbot/dca/testing/FakeExchangeApi.kt | 67 +++++++++++++++++++ .../accbot/dca/testing/HarnessSanityTest.kt | 53 +++++++++++++++ .../java/com/accbot/dca/testing/InMemoryDb.kt | 17 +++++ .../src/test/resources/robolectric.properties | 1 + 5 files changed, 154 insertions(+) create mode 100644 accbot-android/app/src/test/java/com/accbot/dca/testing/FakeExchangeApi.kt create mode 100644 accbot-android/app/src/test/java/com/accbot/dca/testing/HarnessSanityTest.kt create mode 100644 accbot-android/app/src/test/java/com/accbot/dca/testing/InMemoryDb.kt create mode 100644 accbot-android/app/src/test/resources/robolectric.properties diff --git a/accbot-android/app/build.gradle.kts b/accbot-android/app/build.gradle.kts index c77465d..5f71d38 100644 --- a/accbot-android/app/build.gradle.kts +++ b/accbot-android/app/build.gradle.kts @@ -73,6 +73,13 @@ android { buildConfig = true } + testOptions { + unitTests { + isIncludeAndroidResources = true + isReturnDefaultValues = true + } + } + experimentalProperties["android.experimental.enableScreenshotTest"] = true packaging { @@ -149,6 +156,15 @@ dependencies { // Testing testImplementation("junit:junit:4.13.2") + // JVM unit tests: Robolectric + in-memory Room + coroutines/WorkManager test helpers + testImplementation("org.robolectric:robolectric:4.14.1") + testImplementation("androidx.test:core-ktx:1.6.1") + testImplementation("androidx.test.ext:junit-ktx:1.2.1") + testImplementation("androidx.room:room-testing:2.8.4") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + testImplementation("androidx.work:work-testing:2.11.0") + testImplementation("androidx.arch.core:core-testing:2.2.0") + testImplementation("com.squareup.okhttp3:mockwebserver:5.3.0") androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0") diff --git a/accbot-android/app/src/test/java/com/accbot/dca/testing/FakeExchangeApi.kt b/accbot-android/app/src/test/java/com/accbot/dca/testing/FakeExchangeApi.kt new file mode 100644 index 0000000..14c383c --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/testing/FakeExchangeApi.kt @@ -0,0 +1,67 @@ +package com.accbot.dca.testing + +import com.accbot.dca.domain.model.DcaResult +import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.TradeHistoryPage +import com.accbot.dca.exchange.ExchangeApi +import com.accbot.dca.exchange.OrderStatusResult +import java.math.BigDecimal +import java.time.Instant + +/** + * Configurable fake [ExchangeApi] for JVM unit tests. + * + * Each behaviour is a swappable lambda so tests can simulate timeouts (throw), + * successful buys, and trade-history reconciliation scenarios deterministically. + */ +class FakeExchangeApi( + override val exchange: Exchange = Exchange.COINMATE, + override val supportsLimitSell: Boolean = true, + var marketBuyHandler: suspend (crypto: String, fiat: String, fiatAmount: BigDecimal) -> DcaResult = + { _, _, _ -> DcaResult.Error("not configured", retryable = false) }, + var tradeHistoryHandler: suspend (crypto: String, fiat: String, since: Instant?, limit: Int) -> TradeHistoryPage = + { _, _, _, _ -> TradeHistoryPage(emptyList(), hasMore = false) }, + var limitSellHandler: suspend (crypto: String, fiat: String, cryptoAmount: BigDecimal, limitPrice: BigDecimal) -> DcaResult = + { _, _, _, _ -> DcaResult.Error("not configured", retryable = false) }, + var balance: BigDecimal? = BigDecimal("1000"), + var price: BigDecimal? = BigDecimal("1500000") +) : ExchangeApi { + + /** Number of times [marketBuy] was invoked - lets tests assert "no duplicate order". */ + var marketBuyCallCount: Int = 0 + private set + + /** Number of times [limitSell] was invoked. */ + var limitSellCallCount: Int = 0 + private set + + override suspend fun marketBuy(crypto: String, fiat: String, fiatAmount: BigDecimal): DcaResult { + marketBuyCallCount++ + return marketBuyHandler(crypto, fiat, fiatAmount) + } + + override suspend fun limitSell( + crypto: String, + fiat: String, + cryptoAmount: BigDecimal, + limitPrice: BigDecimal + ): DcaResult { + limitSellCallCount++ + return limitSellHandler(crypto, fiat, cryptoAmount, limitPrice) + } + + override suspend fun getTradeHistory( + crypto: String, + fiat: String, + sinceTimestamp: Instant?, + limit: Int + ): TradeHistoryPage = tradeHistoryHandler(crypto, fiat, sinceTimestamp, limit) + + override suspend fun getBalance(currency: String): BigDecimal? = balance + override suspend fun getCurrentPrice(crypto: String, fiat: String): BigDecimal? = price + override suspend fun withdraw(crypto: String, amount: BigDecimal, address: String): Result = + Result.success("fake-withdrawal") + override suspend fun getWithdrawalFee(crypto: String): BigDecimal? = BigDecimal.ZERO + override suspend fun validateCredentials(): Boolean = true + override suspend fun getOrderStatus(orderId: String, crypto: String, fiat: String): OrderStatusResult? = null +} diff --git a/accbot-android/app/src/test/java/com/accbot/dca/testing/HarnessSanityTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/testing/HarnessSanityTest.kt new file mode 100644 index 0000000..c771eaa --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/testing/HarnessSanityTest.kt @@ -0,0 +1,53 @@ +package com.accbot.dca.testing + +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.DcaPlanEntity +import com.accbot.dca.domain.model.DcaFrequency +import com.accbot.dca.domain.model.Exchange +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.math.BigDecimal + +/** + * Validates that the Robolectric + in-memory Room harness works before we build on it. + */ +@RunWith(RobolectricTestRunner::class) +class HarnessSanityTest { + + private lateinit var db: DcaDatabase + + @Before + fun setUp() { + db = buildInMemoryDb() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `inserts and reads back a plan`() = runTest { + val id = db.dcaPlanDao().insertPlan( + DcaPlanEntity( + exchange = Exchange.COINMATE, + connectionId = 6, + crypto = "BTC", + fiat = "CZK", + amount = BigDecimal("50"), + frequency = DcaFrequency.EVERY_8_HOURS + ) + ) + + val plans = db.dcaPlanDao().getEnabledPlans() + + assertEquals(1, plans.size) + assertEquals(id, plans.first().id) + assertEquals(6L, plans.first().connectionId) + } +} diff --git a/accbot-android/app/src/test/java/com/accbot/dca/testing/InMemoryDb.kt b/accbot-android/app/src/test/java/com/accbot/dca/testing/InMemoryDb.kt new file mode 100644 index 0000000..85ebf9c --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/testing/InMemoryDb.kt @@ -0,0 +1,17 @@ +package com.accbot.dca.testing + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.accbot.dca.data.local.DcaDatabase + +/** + * Builds an in-memory [DcaDatabase] for JVM unit tests (Robolectric). + * Allows main-thread queries so tests can use the *Sync DAO methods directly. + */ +fun buildInMemoryDb(): DcaDatabase = + Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + DcaDatabase::class.java + ) + .allowMainThreadQueries() + .build() diff --git a/accbot-android/app/src/test/resources/robolectric.properties b/accbot-android/app/src/test/resources/robolectric.properties new file mode 100644 index 0000000..979b5ee --- /dev/null +++ b/accbot-android/app/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=34 From 02d6a593214b3023e6abd2f05470e05a4f8a3d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 5 Jun 2026 09:48:09 +0200 Subject: [PATCH 68/75] fix(history): attribute imported transactions to a connection Trade-history import left transactions.connectionId NULL, which broke per-connection aggregation and backup-restore dedup (keyed on orderId+connectionId). Now resolves the plan's connectionId on import, plus migration 21->22 backfills existing NULL rows from their plan. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/accbot/dca/data/local/DcaDatabase.kt | 21 +++++- .../usecase/ImportTradeHistoryUseCase.kt | 12 +++- .../dca/data/local/Migration21To22Test.kt | 67 +++++++++++++++++++ .../ImportTradeHistoryConnectionIdTest.kt | 65 ++++++++++++++++++ 4 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 accbot-android/app/src/test/java/com/accbot/dca/data/local/Migration21To22Test.kt create mode 100644 accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ImportTradeHistoryConnectionIdTest.kt diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt index 3186da7..3e89b41 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt @@ -20,7 +20,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase WithdrawalThresholdEntity::class, ExchangeConnectionEntity::class ], - version = 21, + version = 22, exportSchema = true ) @TypeConverters(Converters::class) @@ -392,6 +392,23 @@ abstract class DcaDatabase : RoomDatabase() { } } + // Migration 21 -> 22: backfill transactions.connectionId from the parent plan. + // Trade-history imports (and pre-v19 rows) left connectionId NULL, which breaks + // per-connection aggregation and backup-restore dedup. Only backfill real + // connections (> 0); leave NULL where the plan has no connection. + internal val MIGRATION_21_22 = object : Migration(21, 22) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + UPDATE transactions + SET connectionId = (SELECT p.connectionId FROM dca_plans p WHERE p.id = transactions.planId) + WHERE connectionId IS NULL + AND EXISTS (SELECT 1 FROM dca_plans p WHERE p.id = transactions.planId AND p.connectionId > 0) + """.trimIndent() + ) + } + } + // Migration from version 9 to 10: Add notifications and withdrawal_thresholds tables private val MIGRATION_9_10 = object : Migration(9, 10) { override fun migrate(database: SupportSQLiteDatabase) { @@ -494,7 +511,7 @@ abstract class DcaDatabase : RoomDatabase() { DcaDatabase::class.java, databaseName ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22) // Only allow destructive migration on app downgrade, never on failed upgrade // This protects user's transaction history from accidental deletion .fallbackToDestructiveMigrationOnDowngrade() diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ImportTradeHistoryUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ImportTradeHistoryUseCase.kt index 4262a8b..0b8e47a 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ImportTradeHistoryUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ImportTradeHistoryUseCase.kt @@ -1,5 +1,6 @@ package com.accbot.dca.domain.usecase +import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.TransactionDao import com.accbot.dca.data.local.TransactionEntity import com.accbot.dca.domain.model.Exchange @@ -25,7 +26,8 @@ sealed class ApiImportProgress { } class ImportTradeHistoryUseCase @Inject constructor( - private val transactionDao: TransactionDao + private val transactionDao: TransactionDao, + private val dcaPlanDao: DcaPlanDao ) { fun importFromApi( api: ExchangeApi, @@ -90,11 +92,19 @@ class ImportTradeHistoryUseCase @Inject constructor( emit(ApiImportProgress.Importing(newTrades.size)) + // Resolve the plan's connectionId so imported rows are attributed to the right + // account. Leaving it null breaks per-connection aggregation and backup-restore + // dedup (which keys on (orderId, connectionId)). Use the suspend DAO variant - + // the flow runs on the caller's (main) thread and a blocking Room query there + // throws "cannot access database on the main thread". + val connectionId = dcaPlanDao.getPlanById(planId)?.connectionId + // Map to TransactionEntity and batch insert val entities = newTrades.map { trade -> TransactionEntity( planId = planId, exchange = exchange, + connectionId = connectionId, crypto = trade.crypto, fiat = trade.fiat, fiatAmount = trade.fiatAmount, diff --git a/accbot-android/app/src/test/java/com/accbot/dca/data/local/Migration21To22Test.kt b/accbot-android/app/src/test/java/com/accbot/dca/data/local/Migration21To22Test.kt new file mode 100644 index 0000000..c7863ff --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/data/local/Migration21To22Test.kt @@ -0,0 +1,67 @@ +package com.accbot.dca.data.local + +import com.accbot.dca.domain.model.DcaFrequency +import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.TransactionStatus +import com.accbot.dca.testing.buildInMemoryDb +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.math.BigDecimal +import java.time.Instant + +/** + * Verifies the backfill SQL in MIGRATION_21_22: NULL connectionId rows inherit the plan's + * connection, but only when the plan actually has one (> 0). + */ +@RunWith(RobolectricTestRunner::class) +class Migration21To22Test { + + private lateinit var db: DcaDatabase + + @Before + fun setUp() { + db = buildInMemoryDb() + } + + @After + fun tearDown() = db.close() + + private suspend fun tx(planId: Long, orderId: String) = db.transactionDao().insertTransaction( + TransactionEntity( + planId = planId, exchange = Exchange.COINMATE, connectionId = null, + crypto = "BTC", fiat = "CZK", fiatAmount = BigDecimal("50"), + cryptoAmount = BigDecimal("0.00003"), price = BigDecimal("1500000"), + fee = BigDecimal("0.17"), status = TransactionStatus.COMPLETED, + exchangeOrderId = orderId, executedAt = Instant.now() + ) + ) + + @Test + fun `backfills connectionId from plan, leaves rows without a real connection NULL`() = runTest { + val planWithConn = db.dcaPlanDao().insertPlan( + DcaPlanEntity( + exchange = Exchange.COINMATE, connectionId = 6, crypto = "BTC", fiat = "CZK", + amount = BigDecimal("50"), frequency = DcaFrequency.EVERY_8_HOURS + ) + ) + val planNoConn = db.dcaPlanDao().insertPlan( + DcaPlanEntity( + exchange = Exchange.COINMATE, connectionId = 0, crypto = "BTC", fiat = "CZK", + amount = BigDecimal("50"), frequency = DcaFrequency.EVERY_8_HOURS + ) + ) + tx(planWithConn, "orphan-6") + tx(planNoConn, "orphan-0") + + DcaDatabase.MIGRATION_21_22.migrate(db.openHelper.writableDatabase) + + assertEquals(6L, db.transactionDao().getByExchangeOrderId("orphan-6")?.connectionId) + assertNull(db.transactionDao().getByExchangeOrderId("orphan-0")?.connectionId) + } +} diff --git a/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ImportTradeHistoryConnectionIdTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ImportTradeHistoryConnectionIdTest.kt new file mode 100644 index 0000000..f363add --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ImportTradeHistoryConnectionIdTest.kt @@ -0,0 +1,65 @@ +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.DcaPlanEntity +import com.accbot.dca.domain.model.DcaFrequency +import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.HistoricalTrade +import com.accbot.dca.domain.model.TradeHistoryPage +import com.accbot.dca.testing.FakeExchangeApi +import com.accbot.dca.testing.buildInMemoryDb +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.math.BigDecimal +import java.time.Instant + +@RunWith(RobolectricTestRunner::class) +class ImportTradeHistoryConnectionIdTest { + + private lateinit var db: DcaDatabase + private lateinit var useCase: ImportTradeHistoryUseCase + + @Before + fun setUp() { + db = buildInMemoryDb() + useCase = ImportTradeHistoryUseCase(db.transactionDao(), db.dcaPlanDao()) + } + + @After + fun tearDown() = db.close() + + @Test + fun `imported transactions inherit the plan's connectionId`() = runTest { + val planId = db.dcaPlanDao().insertPlan( + DcaPlanEntity( + exchange = Exchange.COINMATE, connectionId = 6, + crypto = "BTC", fiat = "CZK", + amount = BigDecimal("50"), frequency = DcaFrequency.EVERY_8_HOURS + ) + ) + val api = FakeExchangeApi(tradeHistoryHandler = { _, _, _, _ -> + TradeHistoryPage( + listOf( + HistoricalTrade( + orderId = "999", timestamp = Instant.parse("2026-05-28T19:31:00Z"), + crypto = "BTC", fiat = "CZK", cryptoAmount = BigDecimal("0.00003"), + fiatAmount = BigDecimal("50"), price = BigDecimal("1500000"), + fee = BigDecimal("0.17"), feeAsset = "CZK", side = "BUY" + ) + ), + hasMore = false + ) + }) + + useCase.importFromApi(api, planId, "BTC", "CZK", Exchange.COINMATE).toList() + + val tx = db.transactionDao().getByExchangeOrderIdAndConnection("999", 6) + assertEquals(6L, tx?.connectionId) + } +} From edf53733dced343eb0468e917fe85032231c0e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 5 Jun 2026 09:48:21 +0200 Subject: [PATCH 69/75] feat(history): filter transaction history by plan 'View all transactions' from plan detail now filters to that plan, the active plan filter shows as a clearable chip, and the filter sheet gains a plan picker. Threads planId through the History route, ViewModel and the getFilteredTransactions DAO query. Note: Daos.kt/strings.xml in this commit also carry additive helpers (countCompletedBuysSinceSync, resetNetworkRetrySync, runaway notification string) used by the follow-up runaway-buy fix - they share these files. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/com/accbot/dca/MainActivity.kt | 5 +- .../java/com/accbot/dca/data/local/Daos.kt | 15 +++++- .../dca/presentation/navigation/Screen.kt | 5 +- .../dca/presentation/screens/HistoryScreen.kt | 48 +++++++++++++++++++ .../presentation/screens/HistoryViewModel.kt | 35 +++++++++++--- .../app/src/main/res/values-cs/strings.xml | 2 + .../app/src/main/res/values/strings.xml | 2 + .../local/FilteredTransactionsByPlanTest.kt | 46 ++++++++++++++++++ 8 files changed, 147 insertions(+), 11 deletions(-) create mode 100644 accbot-android/app/src/test/java/com/accbot/dca/data/local/FilteredTransactionsByPlanTest.kt diff --git a/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt b/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt index 412b145..56bfd6e 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt @@ -433,7 +433,7 @@ fun AccBotApp( navController.navigate(Screen.EditPlan.createRoute(planId)) }, onNavigateToHistory = { crypto, fiat -> - navController.navigate(Screen.History.createRoute(crypto, fiat)) + navController.navigate(Screen.History.createRoute(crypto, fiat, planId)) }, onNavigateToTransactionDetails = { transactionId -> navController.navigate(Screen.TransactionDetails.createRoute(transactionId)) @@ -503,7 +503,8 @@ fun AccBotApp( route = Screen.History.route, arguments = listOf( navArgument("crypto") { type = NavType.StringType; nullable = true; defaultValue = null }, - navArgument("fiat") { type = NavType.StringType; nullable = true; defaultValue = null } + navArgument("fiat") { type = NavType.StringType; nullable = true; defaultValue = null }, + navArgument("planId") { type = NavType.StringType; nullable = true; defaultValue = null } ) ) { HistoryScreen( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt index 06501e9..b7f339b 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt @@ -116,6 +116,9 @@ interface DcaPlanDao { @Query("UPDATE dca_plans SET networkRetryCount = 0, nextNetworkRetryAt = NULL, originalScheduledAt = NULL WHERE id = :planId") suspend fun resetNetworkRetry(planId: Long) + @Query("UPDATE dca_plans SET networkRetryCount = 0, nextNetworkRetryAt = NULL, originalScheduledAt = NULL WHERE id = :planId") + fun resetNetworkRetrySync(planId: Long) + @Query("UPDATE dca_plans SET missedPurchaseCount = :count WHERE id = :planId") suspend fun setMissedPurchaseCount(planId: Long, count: Int) @@ -234,12 +237,14 @@ interface TransactionDao { WHERE (:crypto IS NULL OR crypto = :crypto) AND (:exchange IS NULL OR exchange = :exchange) AND (:status IS NULL OR status = :status) + AND (:planId IS NULL OR planId = :planId) ORDER BY executedAt DESC """) fun getFilteredTransactions( crypto: String?, exchange: String?, - status: String? + status: String?, + planId: Long? ): Flow> @Query("SELECT * FROM transactions ORDER BY executedAt DESC LIMIT :limit") @@ -335,6 +340,14 @@ interface TransactionDao { @Query("SELECT exchangeOrderId FROM transactions WHERE planId = :planId AND exchangeOrderId IS NOT NULL") suspend fun getExchangeOrderIdsByPlan(planId: Long): List + /** + * Number of completed BUY transactions for a plan executed at or after [since]. + * Used by the runaway circuit breaker - counts real spend, including buys recovered + * via reconciliation, so it reflects what actually hit the exchange. + */ + @Query("SELECT COUNT(*) FROM transactions WHERE planId = :planId AND side = 'BUY' AND status = 'COMPLETED' AND executedAt >= :since") + fun countCompletedBuysSinceSync(planId: Long, since: Instant): Int + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertTransaction(transaction: TransactionEntity): Long diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/navigation/Screen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/navigation/Screen.kt index 1847da2..3e71710 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/navigation/Screen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/navigation/Screen.kt @@ -53,11 +53,12 @@ sealed class Screen(val route: String) { } // History screens - data object History : Screen("history?crypto={crypto}&fiat={fiat}") { - fun createRoute(crypto: String? = null, fiat: String? = null): String { + data object History : Screen("history?crypto={crypto}&fiat={fiat}&planId={planId}") { + fun createRoute(crypto: String? = null, fiat: String? = null, planId: Long? = null): String { val params = buildList { if (crypto != null) add("crypto=$crypto") if (fiat != null) add("fiat=$fiat") + if (planId != null) add("planId=$planId") } return if (params.isEmpty()) "history" else "history?${params.joinToString("&")}" } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt index 3a3f97d..d6115f8 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt @@ -102,6 +102,7 @@ fun HistoryScreen( currentFilter = uiState.filter, availableCryptos = uiState.availableCryptos, availableExchanges = uiState.availableExchanges, + availablePlans = uiState.availablePlans, onApplyFilter = { filter -> viewModel.setFilter(filter) viewModel.hideFilterSheet() @@ -141,6 +142,7 @@ fun HistoryScreen( val hasActiveFilter = uiState.filter.crypto != null || uiState.filter.exchange != null || uiState.filter.status != null || + uiState.filter.planId != null || uiState.filter.dateFrom != null || uiState.filter.dateTo != null var showSearchBar by rememberSaveable { mutableStateOf(false) } @@ -242,6 +244,7 @@ fun HistoryScreen( if (hasActiveFilter) { ActiveFilterChips( filter = uiState.filter, + availablePlans = uiState.availablePlans, onUpdateFilter = { viewModel.setFilter(it) }, onClearFilter = { viewModel.clearFilter() } ) @@ -340,6 +343,7 @@ private fun SortDropdownMenu( @Composable private fun ActiveFilterChips( filter: HistoryFilter, + availablePlans: List, onUpdateFilter: (HistoryFilter) -> Unit, onClearFilter: () -> Unit ) { @@ -351,6 +355,20 @@ private fun ActiveFilterChips( .padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { + filter.planId?.let { planId -> + item { + val label = availablePlans.firstOrNull { it.id == planId }?.label + ?: stringResource(R.string.history_filter_plan) + SelectableChip( + text = label, + selected = true, + onClick = { onUpdateFilter(filter.copy(planId = null)) }, + trailingIcon = { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.common_remove), modifier = Modifier.size(16.dp)) + } + ) + } + } filter.crypto?.let { crypto -> item { SelectableChip( @@ -479,6 +497,7 @@ private fun FilterBottomSheet( currentFilter: HistoryFilter, availableCryptos: List, availableExchanges: List, + availablePlans: List, onApplyFilter: (HistoryFilter) -> Unit, onClearFilter: () -> Unit, onDismiss: () -> Unit @@ -486,6 +505,7 @@ private fun FilterBottomSheet( var selectedCrypto by rememberSaveable { mutableStateOf(currentFilter.crypto) } var selectedExchange by rememberSaveable { mutableStateOf(currentFilter.exchange) } var selectedStatus by rememberSaveable { mutableStateOf(currentFilter.status) } + var selectedPlanId by rememberSaveable { mutableStateOf(currentFilter.planId) } var selectedDateFrom by rememberSaveable { mutableStateOf(currentFilter.dateFrom) } var selectedDateTo by rememberSaveable { mutableStateOf(currentFilter.dateTo) } var showDateFromPicker by rememberSaveable { mutableStateOf(false) } @@ -583,6 +603,33 @@ private fun FilterBottomSheet( Spacer(modifier = Modifier.height(16.dp)) } + // Plan filter + if (availablePlans.isNotEmpty()) { + Text( + text = stringResource(R.string.history_filter_plan), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(8.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + item { + SelectableChip( + text = stringResource(R.string.common_all), + selected = selectedPlanId == null, + onClick = { selectedPlanId = null } + ) + } + items(availablePlans, key = { it.id }) { plan -> + SelectableChip( + text = plan.label, + selected = selectedPlanId == plan.id, + onClick = { selectedPlanId = plan.id } + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + // Exchange filter if (availableExchanges.isNotEmpty()) { Text( @@ -693,6 +740,7 @@ private fun FilterBottomSheet( crypto = selectedCrypto, exchange = selectedExchange, status = selectedStatus, + planId = selectedPlanId, dateFrom = selectedDateFrom, dateTo = selectedDateTo ) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt index 8127971..ddc2bc8 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt @@ -3,6 +3,7 @@ package com.accbot.dca.presentation.screens import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.TransactionDao import com.accbot.dca.data.local.TransactionEntity import com.accbot.dca.domain.model.TransactionSide @@ -38,12 +39,19 @@ data class HistoryFilter( val crypto: String? = null, val exchange: String? = null, val status: TransactionStatus? = null, + val planId: Long? = null, val dateFrom: Long? = null, val dateTo: Long? = null, val searchQuery: String = "", val sideFilter: HistorySideFilter = HistorySideFilter.ALL ) +/** A plan the user can filter history by, shown in the filter sheet. */ +data class HistoryPlanOption( + val id: Long, + val label: String +) + /** * Data class for CSV export result to pass to UI for file handling. */ @@ -59,6 +67,7 @@ data class HistoryUiState( val sortOption: SortOption = SortOption.DATE_NEWEST, val availableCryptos: List = emptyList(), val availableExchanges: List = emptyList(), + val availablePlans: List = emptyList(), val showFilterSheet: Boolean = false, val isExporting: Boolean = false, val exportSuccess: Boolean = false, @@ -72,13 +81,17 @@ data class HistoryUiState( class HistoryViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val transactionDao: TransactionDao, + private val dcaPlanDao: DcaPlanDao, private val exportTransactionsToCsvUseCase: ExportTransactionsToCsvUseCase ) : ViewModel() { private val initialCrypto: String? = savedStateHandle["crypto"] private val initialFiat: String? = savedStateHandle["fiat"] + // planId arrives as a string query param; treat <= 0 / missing as "no plan filter". + private val initialPlanId: Long? = + savedStateHandle.get("planId")?.toLongOrNull()?.takeIf { it > 0 } - private val _filterState = MutableStateFlow(HistoryFilter(crypto = initialCrypto)) + private val _filterState = MutableStateFlow(HistoryFilter(crypto = initialCrypto, planId = initialPlanId)) private val _searchQuery = MutableStateFlow("") private val _sortOption = MutableStateFlow(SortOption.DATE_NEWEST) @@ -87,7 +100,7 @@ class HistoryViewModel @Inject constructor( ) // Extract SQL-pushable chip filters and switch DAO query only when they change - private data class ChipFilter(val crypto: String?, val exchange: String?, val status: String?) + private data class ChipFilter(val crypto: String?, val exchange: String?, val status: String?, val planId: Long?) @OptIn(FlowPreview::class) private val _debouncedSearch = _searchQuery.debounce(300) @@ -95,10 +108,10 @@ class HistoryViewModel @Inject constructor( // Stage 1: SQL-filtered data + in-memory date/search/sort @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) private val _computedTransactions: Flow = _filterState - .map { ChipFilter(it.crypto, it.exchange, it.status?.name) } + .map { ChipFilter(it.crypto, it.exchange, it.status?.name, it.planId) } .distinctUntilChanged() .flatMapLatest { chip -> - transactionDao.getFilteredTransactions(chip.crypto, chip.exchange, chip.status) + transactionDao.getFilteredTransactions(chip.crypto, chip.exchange, chip.status, chip.planId) } .combine(_filterState) { transactions, filter -> transactions to filter } .combine(_debouncedSearch) { (transactions, filter), searchQuery -> @@ -144,6 +157,7 @@ class HistoryViewModel @Inject constructor( computed.copy( availableCryptos = extras.availableCryptos, availableExchanges = extras.availableExchanges, + availablePlans = extras.availablePlans, showFilterSheet = extras.showFilterSheet, isExporting = extras.isExporting, exportSuccess = extras.exportSuccess, @@ -151,7 +165,7 @@ class HistoryViewModel @Inject constructor( exportData = extras.exportData, snackbarMessage = extras.snackbarMessage ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HistoryUiState(filter = HistoryFilter(crypto = initialCrypto))) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HistoryUiState(filter = HistoryFilter(crypto = initialCrypto, planId = initialPlanId))) init { loadFilterOptions() @@ -161,11 +175,20 @@ class HistoryViewModel @Inject constructor( viewModelScope.launch { val cryptosDeferred = async { transactionDao.getDistinctCryptos() } val exchangesDeferred = async { transactionDao.getDistinctExchanges() } + val plansDeferred = async { + dcaPlanDao.getAllPlansOnce().map { plan -> + HistoryPlanOption( + id = plan.id, + label = plan.name.ifBlank { "${plan.crypto}/${plan.fiat}" } + ) + } + } _uiExtras.update { it.copy( availableCryptos = cryptosDeferred.await(), - availableExchanges = exchangesDeferred.await() + availableExchanges = exchangesDeferred.await(), + availablePlans = plansDeferred.await() ) } } diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index fa34206..6a43a26 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -233,6 +233,7 @@ Vymazat vše Filtrovat transakce Kryptoměna + Plán Burza Stav %1$d transakcí @@ -611,6 +612,7 @@ DCA selhalo Nepodařilo se nakoupit %1$s: %2$s DCA chyba + Plán pozastaven: zjištěno neobvykle mnoho nákupů. Zkontroluj účet a po vyřešení plán znovu zapni. Nízký zůstatek na %1$s %1$s %2$s zbývá pro DCA < 1 den diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 0cda724..76400d8 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -232,6 +232,7 @@ Clear all Filter Transactions Cryptocurrency + Plan Exchange Status %1$d transaction(s) @@ -610,6 +611,7 @@ DCA Failed Failed to buy %1$s: %2$s DCA Error + Plan paused: an unusually high number of purchases was detected. Check your account and re-enable once resolved. Low balance on %1$s %1$s of %2$s remaining for DCA < 1 day diff --git a/accbot-android/app/src/test/java/com/accbot/dca/data/local/FilteredTransactionsByPlanTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/data/local/FilteredTransactionsByPlanTest.kt new file mode 100644 index 0000000..75f6b54 --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/data/local/FilteredTransactionsByPlanTest.kt @@ -0,0 +1,46 @@ +package com.accbot.dca.data.local + +import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.TransactionStatus +import com.accbot.dca.testing.buildInMemoryDb +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.math.BigDecimal +import java.time.Instant + +@RunWith(RobolectricTestRunner::class) +class FilteredTransactionsByPlanTest { + + private lateinit var db: DcaDatabase + + @Before fun setUp() { db = buildInMemoryDb() } + @After fun tearDown() = db.close() + + private suspend fun tx(planId: Long) = db.transactionDao().insertTransaction( + TransactionEntity( + planId = planId, exchange = Exchange.COINMATE, connectionId = planId, + crypto = "BTC", fiat = "CZK", fiatAmount = BigDecimal("50"), + cryptoAmount = BigDecimal("0.00003"), price = BigDecimal("1500000"), + fee = BigDecimal("0.17"), status = TransactionStatus.COMPLETED, + exchangeOrderId = "o$planId-${System.nanoTime()}", executedAt = Instant.now() + ) + ) + + @Test + fun `filters transactions by planId, leaving other plans out`() = runTest { + tx(2); tx(2); tx(4) // two BTC/CZK plans share the same pair + + val all = db.transactionDao().getFilteredTransactions(null, null, null, null).first() + val onlyPlan2 = db.transactionDao().getFilteredTransactions(null, null, null, 2L).first() + + assertEquals(3, all.size) + assertEquals(2, onlyPlan2.size) + assertEquals(setOf(2L), onlyPlan2.map { it.planId }.toSet()) + } +} From 55c3bf9a6ba4c105884f0f5e8a1cb9f0d538ccc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 5 Jun 2026 09:48:42 +0200 Subject: [PATCH 70/75] fix(dca): stop runaway duplicate buy orders after network timeout Market buys are not idempotent: /buyInstant places the order before the client confirms it, so a slow network made withTimeoutOrNull fire after the order was placed, and the worker retried it (and re-armed every 5 min) - draining accounts while reporting 'failed, no internet'. - Reconcile against exchange trade history before any retry; a timed-out buy that actually went through is recorded, never re-issued. - Treat reconciliation uncertainty conservatively (never retry on unknown). - Cap the 5-minute network-retry loop; runaway circuit breaker auto-disables a plan that buys far beyond its schedule (counts reconciled buys too). - OkHttp callTimeout so calls abort deterministically. - Don't surface CancellationException as an error notification. - Note the same orphan-order caveat on the (non-retrying) sell path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/com/accbot/dca/di/AppModule.kt | 5 + .../dca/domain/usecase/BuySafetyPolicy.kt | 32 ++++ .../domain/usecase/PlaceLadderSellUseCase.kt | 6 + .../usecase/ReconcileRecentBuyUseCase.kt | 83 ++++++++++ .../java/com/accbot/dca/worker/DcaWorker.kt | 151 +++++++++++++++--- .../dca/domain/usecase/BuySafetyPolicyTest.kt | 49 ++++++ .../usecase/ReconcileRecentBuyUseCaseTest.kt | 123 ++++++++++++++ 7 files changed, 429 insertions(+), 20 deletions(-) create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/BuySafetyPolicy.kt create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCase.kt create mode 100644 accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/BuySafetyPolicyTest.kt create mode 100644 accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCaseTest.kt diff --git a/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt b/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt index 8b47deb..eb5b875 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt @@ -129,6 +129,11 @@ object AppModule { .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) + // Hard ceiling on a whole call (connect + write + read). Without this, each + // individual request only times out per-phase, so a slow exchange call can hang + // well past the worker's own timeout - the window in which an order gets placed + // server-side but the client sees a "timeout" and (pre-fix) retried it. + .callTimeout(40, TimeUnit.SECONDS) .build() } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/BuySafetyPolicy.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/BuySafetyPolicy.kt new file mode 100644 index 0000000..80daad7 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/BuySafetyPolicy.kt @@ -0,0 +1,32 @@ +package com.accbot.dca.domain.usecase + +/** + * Pure guard-rails that bound how often a single DCA plan can place orders, independent of + * the root cause. Two layers: + * - [shouldRetryAfterConfirmedFailure] caps the 5-minute network-retry loop. + * - [isRunaway] is a circuit breaker: if a plan has bought far more than its schedule + * allows in the last 24h, something is wrong and the plan should be auto-disabled. + */ +object BuySafetyPolicy { + + /** Max times a single slot will retry in 5-minute steps before giving up to the next interval. */ + const val MAX_NETWORK_RETRIES = 3 + + /** A plan may legitimately buy up to this multiple of its expected daily count (catch-up). */ + const val RUNAWAY_FACTOR = 2 + + fun shouldRetryAfterConfirmedFailure(currentRetryCount: Int): Boolean = + currentRetryCount < MAX_NETWORK_RETRIES + + fun expectedBuysPerDay(intervalMinutes: Long): Int { + if (intervalMinutes <= 0) return 1 + return (MINUTES_PER_DAY / intervalMinutes).coerceAtLeast(1).toInt() + } + + fun isRunaway(buysLast24h: Int, expectedBuysPerDay: Int): Boolean { + val cap = maxOf(expectedBuysPerDay * RUNAWAY_FACTOR, expectedBuysPerDay + 2) + return buysLast24h > cap + } + + private const val MINUTES_PER_DAY = 1440L +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt index cbed968..eec2cd7 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/PlaceLadderSellUseCase.kt @@ -58,6 +58,12 @@ class PlaceLadderSellUseCase @Inject constructor( placed += id } is DcaResult.Error -> { + // NOTE (orphan-order caveat): like market buys, limitSell is not idempotent. + // A retryable error means the order MIGHT have been placed server-side even + // though we got no orderId back. We deliberately do NOT auto-retry here, so + // there is no runaway, but a blind user retry could create a duplicate sell. + // Full idempotency needs a per-exchange "list open orders" reconciliation + // (no such API yet) - tracked as a follow-up. return LadderResult.PartialFailure(placed, idx, orders.size, result.message) } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCase.kt new file mode 100644 index 0000000..c1a6aaf --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCase.kt @@ -0,0 +1,83 @@ +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.DcaPlanEntity +import com.accbot.dca.data.local.TransactionDao +import com.accbot.dca.domain.model.HistoricalTrade +import com.accbot.dca.exchange.ExchangeApi +import kotlinx.coroutines.delay +import java.math.BigDecimal +import java.time.Instant + +/** + * Outcome of trying to reconcile a possibly-placed buy against the exchange's trade history. + * + * Distinguishes the dangerous "I don't know" case ([Unknown]) from a confirmed absence + * ([NotFound]) so callers never retry a market order on uncertainty. + */ +sealed interface ReconcileResult { + data class Found(val trade: HistoricalTrade) : ReconcileResult + object NotFound : ReconcileResult + object Unknown : ReconcileResult +} + +/** + * After a buy times out client-side, the order may still have been placed on the exchange. + * This use case asks the exchange "did a matching buy actually happen since [since]?" so the + * worker can record it instead of blindly retrying (which would double-spend). + */ +class ReconcileRecentBuyUseCase( + private val transactionDao: TransactionDao +) { + suspend operator fun invoke( + api: ExchangeApi, + plan: DcaPlanEntity, + since: Instant, + expectedFiat: BigDecimal? + ): ReconcileResult { + val alreadyRecorded = transactionDao.getExchangeOrderIdsByPlan(plan.id).toSet() + + // The fill may not appear in trade history instantly, so look twice with a short + // settlement pause (mirrors CoinmateApi.getTradeDetailsByOrderId). A query that + // throws means we genuinely don't know - return Unknown so the caller stays + // conservative and never retries on uncertainty. + repeat(SETTLEMENT_ATTEMPTS) { attempt -> + if (attempt > 0) delay(SETTLEMENT_DELAY_MS) + + val page = try { + api.getTradeHistory( + crypto = plan.crypto, + fiat = plan.fiat, + sinceTimestamp = since.minusSeconds(LOOKBACK_BUFFER_SECONDS), + limit = PAGE_LIMIT + ) + } catch (_: Exception) { + return ReconcileResult.Unknown + } + + val match = page.trades + .filter { it.side == "BUY" } + .filter { !it.timestamp.isBefore(since) } + .filter { it.orderId.isNotEmpty() && it.orderId !in alreadyRecorded } + .filter { expectedFiat == null || withinTolerance(it.fiatAmount, expectedFiat) } + .maxByOrNull { it.timestamp } + + if (match != null) return ReconcileResult.Found(match) + } + + return ReconcileResult.NotFound + } + + private fun withinTolerance(actual: BigDecimal, expected: BigDecimal): Boolean { + if (expected.signum() == 0) return true + val ratio = actual.toDouble() / expected.toDouble() + return ratio in (1.0 - AMOUNT_TOLERANCE)..(1.0 + AMOUNT_TOLERANCE) + } + + private companion object { + const val SETTLEMENT_ATTEMPTS = 2 + const val SETTLEMENT_DELAY_MS = 1_000L + const val LOOKBACK_BUFFER_SECONDS = 5L + const val PAGE_LIMIT = 50 + const val AMOUNT_TOLERANCE = 0.30 + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt b/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt index 93cb00a..8130a41 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt @@ -12,9 +12,13 @@ import com.accbot.dca.data.local.TransactionEntity import com.accbot.dca.data.local.UserPreferences import com.accbot.dca.domain.model.DcaResult import com.accbot.dca.domain.model.DcaStrategy +import com.accbot.dca.domain.model.Transaction import com.accbot.dca.domain.model.TransactionStatus import com.accbot.dca.domain.util.CronUtils +import com.accbot.dca.domain.usecase.BuySafetyPolicy import com.accbot.dca.domain.usecase.CalculateStrategyMultiplierUseCase +import com.accbot.dca.domain.usecase.ReconcileRecentBuyUseCase +import com.accbot.dca.domain.usecase.ReconcileResult import com.accbot.dca.domain.usecase.ResolvePendingTransactionsUseCase import com.accbot.dca.exchange.ExchangeApi import com.accbot.dca.exchange.ExchangeApiFactory @@ -195,6 +199,30 @@ class DcaWorker @AssistedInject constructor( continue } + // Circuit breaker: if this plan has already bought far more than its schedule + // allows in the last 24h, something is wrong - auto-disable instead of continuing + // to spend. Counts reconciled buys too, so it reflects real exchange spend. + // Skipped for forceRun (user-initiated catch-up is intentional). + if (!forceRun) { + val buysLast24h = database.transactionDao() + .countCompletedBuysSinceSync(plan.id, now.minus(Duration.ofHours(24))) + val expectedPerDay = BuySafetyPolicy.expectedBuysPerDay(effectiveIntervalMinutes(plan)) + if (BuySafetyPolicy.isRunaway(buysLast24h, expectedPerDay)) { + Log.e(TAG, "Plan ${plan.id} runaway detected ($buysLast24h buys/24h, expected ~$expectedPerDay) - auto-disabling") + database.dcaPlanDao().setEnabled(plan.id, false) + notificationService.showErrorNotification( + planId = plan.id, + exchange = plan.exchange, + connectionId = plan.connectionId, + templateArgs = NotificationTemplateArgs.Error( + crypto = plan.crypto, + errorMessage = context.getString(R.string.notification_runaway_disabled) + ) + ) + continue + } + } + // Atomically claim the plan to prevent double-purchase from concurrent workers. // claimPlanForExecutionSync advances nextExecutionAt only if it's still in the past // (or null), returning 0 if another worker already claimed it. @@ -208,14 +236,20 @@ class DcaWorker @AssistedInject constructor( Log.d(TAG, "Plan ${plan.id} claimed for execution, nextExecution advanced to $nextExec") } - // Execute DCA purchase with immediate retry + // Execute DCA purchase. A market buy is NOT idempotent: the order may be placed + // server-side even when the client sees a timeout/network error. So before EVER + // re-issuing a buy, reconcile against the exchange to see whether the order + // actually went through - this prevents the runaway duplicate-spend bug. val api = exchangeApiFactory.create(credentials) + val reconcileRecentBuy = ReconcileRecentBuyUseCase(database.transactionDao()) + val attemptStart = Instant.now() val maxAttempts = 3 val retryDelayMs = 2_000L val failedAttemptMessages = mutableListOf() var finalResult: DcaResult? = null + var reconcileUncertain = false - for (attempt in 1..maxAttempts) { + attemptLoop@ for (attempt in 1..maxAttempts) { val attemptResult = withTimeoutOrNull(30_000L) { api.marketBuy(plan.crypto, plan.fiat, purchaseAmount) } ?: DcaResult.Error("API call timed out after 30s", retryable = true) @@ -229,6 +263,46 @@ class DcaWorker @AssistedInject constructor( failedAttemptMessages.add("Attempt $attempt: ${error.message}") Log.w(TAG, "Plan ${plan.id} attempt $attempt/$maxAttempts failed: ${error.message}") + if (!error.retryable) { + // Business error (e.g. insufficient balance) - no order placed, don't retry. + finalResult = error + break + } + + // Ambiguous failure - did the order actually go through on the exchange? + when (val rec = reconcileRecentBuy(api, plan, attemptStart, purchaseAmount)) { + is ReconcileResult.Found -> { + Log.w(TAG, "Plan ${plan.id} buy timed out client-side but order ${rec.trade.orderId} exists on exchange - recording, not retrying") + finalResult = DcaResult.Success( + Transaction( + planId = plan.id, + exchange = plan.exchange, + crypto = plan.crypto, + fiat = plan.fiat, + fiatAmount = rec.trade.fiatAmount, + cryptoAmount = rec.trade.cryptoAmount, + price = rec.trade.price, + fee = rec.trade.fee, + feeAsset = rec.trade.feeAsset, + status = TransactionStatus.COMPLETED, + exchangeOrderId = rec.trade.orderId, + executedAt = rec.trade.timestamp + ) + ) + break@attemptLoop + } + ReconcileResult.Unknown -> { + // We don't know whether an order was placed - NEVER retry on uncertainty. + Log.w(TAG, "Plan ${plan.id} buy failed and reconciliation inconclusive - not retrying") + reconcileUncertain = true + finalResult = error + break@attemptLoop + } + ReconcileResult.NotFound -> { + // Confirmed: no order exists. Safe to retry. + } + } + if (attempt < maxAttempts) { kotlinx.coroutines.delay(retryDelayMs) } else { @@ -331,28 +405,51 @@ class DcaWorker @AssistedInject constructor( is DcaResult.Error -> { if (finalResult.retryable) { - // Network error – retry in 5 min and notify user. - // Override the claimed nextExecutionAt with an earlier retry time. + // We only reach here when reconciliation confirmed NO order exists + // (safe) or was inconclusive (uncertain). Bound how often we re-issue + // a market buy so a degraded network can never drain the account. try { - val retryTime = now.plus(Duration.ofMinutes(5)) - database.runInTransaction { - database.dcaPlanDao().updateExecutionTimeSync(plan.id, now, retryTime) - database.dcaPlanDao().incrementNetworkRetrySync(plan.id, retryTime, nextExecution ?: now) - } - Log.w(TAG, "Network error for plan ${plan.id}, will retry at $retryTime: ${finalResult.message}") - - // Only notify on first failure, not on subsequent retries - if (plan.networkRetryCount == 0) { - notificationService.showNetworkRetryNotification( - crypto = plan.crypto, - exchangeName = plan.exchange.displayName, - errorMessage = finalResult.message, - nextRetryAt = retryTime, - attemptCount = 1, + val capReached = !BuySafetyPolicy.shouldRetryAfterConfirmedFailure(plan.networkRetryCount) + if (reconcileUncertain || capReached) { + // Stop hammering: advance to the next normal slot and reset. + // A later run / trade-history import records the order if it + // actually went through. + database.runInTransaction { + database.dcaPlanDao().updateExecutionTimeSync(plan.id, now, calculateNextExecution(plan, now)) + database.dcaPlanDao().resetNetworkRetrySync(plan.id) + } + Log.w(TAG, "Plan ${plan.id} giving up this slot (uncertain=$reconcileUncertain, capReached=$capReached): ${finalResult.message}") + notificationService.showErrorNotification( planId = plan.id, exchange = plan.exchange, - connectionId = plan.connectionId + connectionId = plan.connectionId, + templateArgs = NotificationTemplateArgs.Error( + crypto = plan.crypto, + errorMessage = finalResult.message + ) ) + } else { + // Confirmed-failed network buy under the retry cap: retry in 5 min. + val retryTime = now.plus(Duration.ofMinutes(5)) + database.runInTransaction { + database.dcaPlanDao().updateExecutionTimeSync(plan.id, now, retryTime) + database.dcaPlanDao().incrementNetworkRetrySync(plan.id, retryTime, nextExecution ?: now) + } + Log.w(TAG, "Network error for plan ${plan.id}, will retry at $retryTime: ${finalResult.message}") + + // Only notify on first failure, not on subsequent retries + if (plan.networkRetryCount == 0) { + notificationService.showNetworkRetryNotification( + crypto = plan.crypto, + exchangeName = plan.exchange.displayName, + errorMessage = finalResult.message, + nextRetryAt = retryTime, + attemptCount = 1, + planId = plan.id, + exchange = plan.exchange, + connectionId = plan.connectionId + ) + } } } catch (e: Exception) { Log.e(TAG, "Failed to update retry time for plan ${plan.id}", e) @@ -414,6 +511,12 @@ class DcaWorker @AssistedInject constructor( DcaAlarmScheduler.scheduleNextAlarm(context) return Result.success() + } catch (ce: kotlinx.coroutines.CancellationException) { + // Worker was cancelled (e.g. system reclaimed it mid-run). This is not an error - + // don't show a scary "Job was cancelled" notification. Let WorkManager reschedule. + Log.d(TAG, "DcaWorker cancelled", ce) + try { DcaAlarmScheduler.scheduleNextAlarm(context) } catch (_: Exception) {} + throw ce } catch (e: Exception) { Log.e(TAG, "DcaWorker error", e) notificationService.showErrorNotification(context.getString(R.string.notification_dca_error), e.message ?: "Unknown error") @@ -450,6 +553,14 @@ class DcaWorker @AssistedInject constructor( } } + /** Best-effort minutes between executions, used by the runaway circuit breaker. */ + private fun effectiveIntervalMinutes(plan: DcaPlanEntity): Long = + if (plan.cronExpression != null) { + CronUtils.getIntervalMinutesEstimate(plan.cronExpression) ?: 1440L + } else { + plan.frequency.intervalMinutes + } + private suspend fun checkWithdrawalThreshold(plan: DcaPlanEntity, api: ExchangeApi) { try { // Per-connection threshold lookup; the plan carries connectionId since migration v18→v19. diff --git a/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/BuySafetyPolicyTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/BuySafetyPolicyTest.kt new file mode 100644 index 0000000..4d383ec --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/BuySafetyPolicyTest.kt @@ -0,0 +1,49 @@ +package com.accbot.dca.domain.usecase + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure-logic guards that bound how often a single plan can fire. No Android deps. + */ +class BuySafetyPolicyTest { + + @Test + fun `retries soon while under the network-retry cap`() { + assertTrue(BuySafetyPolicy.shouldRetryAfterConfirmedFailure(0)) + assertTrue(BuySafetyPolicy.shouldRetryAfterConfirmedFailure(1)) + assertTrue(BuySafetyPolicy.shouldRetryAfterConfirmedFailure(2)) + } + + @Test + fun `stops the 5-minute retry loop once the cap is reached`() { + assertFalse(BuySafetyPolicy.shouldRetryAfterConfirmedFailure(BuySafetyPolicy.MAX_NETWORK_RETRIES)) + assertFalse(BuySafetyPolicy.shouldRetryAfterConfirmedFailure(99)) + } + + @Test + fun `circuit breaker flags a plan that bought far more than expected`() { + // incident: every-8h plan (3/day) executed 53 times in under 2h + assertTrue(BuySafetyPolicy.isRunaway(buysLast24h = 53, expectedBuysPerDay = 3)) + } + + @Test + fun `circuit breaker tolerates normal cadence and modest catch-up`() { + assertFalse(BuySafetyPolicy.isRunaway(buysLast24h = 3, expectedBuysPerDay = 3)) + assertFalse(BuySafetyPolicy.isRunaway(buysLast24h = 6, expectedBuysPerDay = 3)) + } + + @Test + fun `circuit breaker trips just above twice the expected daily count`() { + assertTrue(BuySafetyPolicy.isRunaway(buysLast24h = 7, expectedBuysPerDay = 3)) + } + + @Test + fun `expected buys per day derived from interval minutes`() { + assertEquals(3, BuySafetyPolicy.expectedBuysPerDay(480)) // every 8h + assertEquals(1, BuySafetyPolicy.expectedBuysPerDay(1440)) // daily + assertEquals(1, BuySafetyPolicy.expectedBuysPerDay(0)) // guard against div-by-zero + } +} diff --git a/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCaseTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCaseTest.kt new file mode 100644 index 0000000..87de475 --- /dev/null +++ b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCaseTest.kt @@ -0,0 +1,123 @@ +package com.accbot.dca.domain.usecase + +import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.DcaPlanEntity +import com.accbot.dca.data.local.TransactionEntity +import com.accbot.dca.domain.model.DcaFrequency +import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.HistoricalTrade +import com.accbot.dca.domain.model.TradeHistoryPage +import com.accbot.dca.domain.model.TransactionStatus +import com.accbot.dca.testing.FakeExchangeApi +import com.accbot.dca.testing.buildInMemoryDb +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.math.BigDecimal +import java.time.Instant + +@RunWith(RobolectricTestRunner::class) +class ReconcileRecentBuyUseCaseTest { + + private lateinit var db: DcaDatabase + private lateinit var useCase: ReconcileRecentBuyUseCase + private val since: Instant = Instant.parse("2026-05-28T19:31:00Z") + + @Before + fun setUp() { + db = buildInMemoryDb() + useCase = ReconcileRecentBuyUseCase(db.transactionDao()) + } + + @After + fun tearDown() = db.close() + + private suspend fun plan(): DcaPlanEntity { + val id = db.dcaPlanDao().insertPlan( + DcaPlanEntity( + exchange = Exchange.COINMATE, connectionId = 6, + crypto = "BTC", fiat = "CZK", + amount = BigDecimal("50"), frequency = DcaFrequency.EVERY_8_HOURS + ) + ) + return db.dcaPlanDao().getPlanById(id)!! + } + + private fun buyTrade(orderId: String, at: Instant, fiat: BigDecimal = BigDecimal("50")) = + HistoricalTrade( + orderId = orderId, timestamp = at, crypto = "BTC", fiat = "CZK", + cryptoAmount = BigDecimal("0.00003"), fiatAmount = fiat, + price = BigDecimal("1500000"), fee = BigDecimal("0.17"), feeAsset = "CZK", side = "BUY" + ) + + @Test + fun `Found when a matching buy exists after attemptStart and not yet recorded`() = runTest { + val api = FakeExchangeApi(tradeHistoryHandler = { _, _, _, _ -> + TradeHistoryPage(listOf(buyTrade("111", since.plusSeconds(10))), hasMore = false) + }) + + val result = useCase(api, plan(), since, expectedFiat = BigDecimal("50")) + + assertTrue(result is ReconcileResult.Found) + assertEquals("111", (result as ReconcileResult.Found).trade.orderId) + } + + @Test + fun `NotFound when exchange reports no buys after attemptStart`() = runTest { + val api = FakeExchangeApi(tradeHistoryHandler = { _, _, _, _ -> + TradeHistoryPage(emptyList(), hasMore = false) + }) + + val result = useCase(api, plan(), since, expectedFiat = BigDecimal("50")) + + assertEquals(ReconcileResult.NotFound, result) + } + + @Test + fun `Unknown when the reconciliation query itself fails - caller must stay conservative`() = runTest { + val api = FakeExchangeApi(tradeHistoryHandler = { _, _, _, _ -> + throw java.io.IOException("history timed out") + }) + + val result = useCase(api, plan(), since, expectedFiat = BigDecimal("50")) + + assertEquals(ReconcileResult.Unknown, result) + } + + @Test + fun `excludes orders already recorded in the DB - no double counting`() = runTest { + val p = plan() + db.transactionDao().insertTransaction( + TransactionEntity( + planId = p.id, exchange = Exchange.COINMATE, connectionId = 6, + crypto = "BTC", fiat = "CZK", fiatAmount = BigDecimal("50"), + cryptoAmount = BigDecimal("0.00003"), price = BigDecimal("1500000"), + fee = BigDecimal("0.17"), status = TransactionStatus.COMPLETED, + exchangeOrderId = "111", executedAt = since.plusSeconds(10) + ) + ) + val api = FakeExchangeApi(tradeHistoryHandler = { _, _, _, _ -> + TradeHistoryPage(listOf(buyTrade("111", since.plusSeconds(10))), hasMore = false) + }) + + val result = useCase(api, p, since, expectedFiat = BigDecimal("50")) + + assertEquals(ReconcileResult.NotFound, result) + } + + @Test + fun `ignores buys that happened before attemptStart`() = runTest { + val api = FakeExchangeApi(tradeHistoryHandler = { _, _, _, _ -> + TradeHistoryPage(listOf(buyTrade("old", since.minusSeconds(120))), hasMore = false) + }) + + val result = useCase(api, plan(), since, expectedFiat = BigDecimal("50")) + + assertEquals(ReconcileResult.NotFound, result) + } +} From 67af1e0cfa62c7c6e196c5dec24f8a4503d78dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 5 Jun 2026 10:05:19 +0200 Subject: [PATCH 71/75] fix(dca): harden buy reconciliation (review findings) Adversarial review of the runaway-buy fix surfaced residual duplicate-order windows, all now closed: - Order placement could outlive the worker's coroutine timeout (30s) while OkHttp's callTimeout was 40s, so reconciliation could run against an in-flight order -> false 'not found' -> duplicate. Now callTimeout(30s) < worker timeout(45s) so OkHttp always aborts first. - Reconciliation now aggregates trade-history fills by orderId before the amount check (partial fills no longer look too small to match). - Honor the lookback buffer in the in-memory filter too (exchange clock skew no longer hides a just-placed order). - Dedup against the whole connection, not just the plan (two plans on one account can't double-claim an order). - Longer settlement window (3x2s) for slow-network fills to appear. - Import attributes connectionId only when > 0 (matches the migration). - History plan picker uses the ordered query; export Room schemas for future migration tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- accbot-android/app/build.gradle.kts | 6 ++ .../java/com/accbot/dca/data/local/Daos.kt | 7 +++ .../main/java/com/accbot/dca/di/AppModule.kt | 8 ++- .../usecase/ImportTradeHistoryUseCase.kt | 4 +- .../usecase/ReconcileRecentBuyUseCase.kt | 53 +++++++++++++++--- .../presentation/screens/HistoryViewModel.kt | 2 +- .../java/com/accbot/dca/worker/DcaWorker.kt | 7 ++- .../usecase/ReconcileRecentBuyUseCaseTest.kt | 55 +++++++++++++++++++ 8 files changed, 127 insertions(+), 15 deletions(-) diff --git a/accbot-android/app/build.gradle.kts b/accbot-android/app/build.gradle.kts index 5f71d38..9c0e897 100644 --- a/accbot-android/app/build.gradle.kts +++ b/accbot-android/app/build.gradle.kts @@ -90,6 +90,12 @@ android { } +// Export Room schemas so future migrations can be tested against real prior versions +// with MigrationTestHelper. (Schemas land in app/schemas/.) +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + dependencies { // Core Android implementation("androidx.appcompat:appcompat:1.7.0") diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt index b7f339b..2ea65e6 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt @@ -340,6 +340,13 @@ interface TransactionDao { @Query("SELECT exchangeOrderId FROM transactions WHERE planId = :planId AND exchangeOrderId IS NOT NULL") suspend fun getExchangeOrderIdsByPlan(planId: Long): List + /** + * All recorded exchange order IDs for a connection. Used by buy reconciliation to avoid + * re-recording an order that another plan on the same account already captured. + */ + @Query("SELECT exchangeOrderId FROM transactions WHERE connectionId = :connectionId AND exchangeOrderId IS NOT NULL") + suspend fun getExchangeOrderIdsByConnection(connectionId: Long): List + /** * Number of completed BUY transactions for a plan executed at or after [since]. * Used by the runaway circuit breaker - counts real spend, including buys recovered diff --git a/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt b/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt index eb5b875..244f6e8 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt @@ -131,9 +131,11 @@ object AppModule { .writeTimeout(30, TimeUnit.SECONDS) // Hard ceiling on a whole call (connect + write + read). Without this, each // individual request only times out per-phase, so a slow exchange call can hang - // well past the worker's own timeout - the window in which an order gets placed - // server-side but the client sees a "timeout" and (pre-fix) retried it. - .callTimeout(40, TimeUnit.SECONDS) + // well past the worker's own timeout. Kept strictly BELOW the worker's + // withTimeoutOrNull around marketBuy so OkHttp aborts the in-flight request + // first - the worker must never start reconciliation while the order POST is + // still on the wire (that would risk a false "not found" and a duplicate buy). + .callTimeout(30, TimeUnit.SECONDS) .build() } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ImportTradeHistoryUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ImportTradeHistoryUseCase.kt index 0b8e47a..f7b8098 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ImportTradeHistoryUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ImportTradeHistoryUseCase.kt @@ -97,7 +97,9 @@ class ImportTradeHistoryUseCase @Inject constructor( // dedup (which keys on (orderId, connectionId)). Use the suspend DAO variant - // the flow runs on the caller's (main) thread and a blocking Room query there // throws "cannot access database on the main thread". - val connectionId = dcaPlanDao.getPlanById(planId)?.connectionId + // Mirror MIGRATION_21_22: only attribute a real connection (> 0); leave null + // for plans without one, so fresh imports and the backfill stay consistent. + val connectionId = dcaPlanDao.getPlanById(planId)?.connectionId?.takeIf { it > 0 } // Map to TransactionEntity and batch insert val entities = newTrades.map { trade -> diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCase.kt index c1a6aaf..6f7dbe8 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCase.kt @@ -34,9 +34,18 @@ class ReconcileRecentBuyUseCase( since: Instant, expectedFiat: BigDecimal? ): ReconcileResult { - val alreadyRecorded = transactionDao.getExchangeOrderIdsByPlan(plan.id).toSet() + // Dedup against the whole connection, not just this plan: two plans on the same + // account+pair must not both claim the same order. + val alreadyRecorded = ( + if (plan.connectionId > 0) transactionDao.getExchangeOrderIdsByConnection(plan.connectionId) + else transactionDao.getExchangeOrderIdsByPlan(plan.id) + ).toSet() - // The fill may not appear in trade history instantly, so look twice with a short + // Allow a small slack before `since`: the exchange stamps fills with its own clock, + // which can be a few seconds behind the device that captured attemptStart. + val cutoff = since.minusSeconds(LOOKBACK_BUFFER_SECONDS) + + // The fill may not appear in trade history instantly, so look a few times with a // settlement pause (mirrors CoinmateApi.getTradeDetailsByOrderId). A query that // throws means we genuinely don't know - return Unknown so the caller stays // conservative and never retries on uncertainty. @@ -47,17 +56,20 @@ class ReconcileRecentBuyUseCase( api.getTradeHistory( crypto = plan.crypto, fiat = plan.fiat, - sinceTimestamp = since.minusSeconds(LOOKBACK_BUFFER_SECONDS), + sinceTimestamp = cutoff, limit = PAGE_LIMIT ) } catch (_: Exception) { return ReconcileResult.Unknown } + // A single market buy can fill across multiple trade rows, so aggregate by + // orderId before matching the amount - otherwise each partial looks too small. val match = page.trades - .filter { it.side == "BUY" } - .filter { !it.timestamp.isBefore(since) } - .filter { it.orderId.isNotEmpty() && it.orderId !in alreadyRecorded } + .filter { it.side == "BUY" && it.orderId.isNotEmpty() && it.orderId !in alreadyRecorded } + .groupBy { it.orderId } + .map { (orderId, fills) -> aggregateOrder(orderId, fills) } + .filter { !it.timestamp.isBefore(cutoff) } .filter { expectedFiat == null || withinTolerance(it.fiatAmount, expectedFiat) } .maxByOrNull { it.timestamp } @@ -67,6 +79,31 @@ class ReconcileRecentBuyUseCase( return ReconcileResult.NotFound } + /** Collapse all fills of one order into a single trade with summed amounts. */ + private fun aggregateOrder(orderId: String, fills: List): HistoricalTrade { + val totalCrypto = fills.fold(BigDecimal.ZERO) { acc, f -> acc + f.cryptoAmount } + val totalFiat = fills.fold(BigDecimal.ZERO) { acc, f -> acc + f.fiatAmount } + val totalFee = fills.fold(BigDecimal.ZERO) { acc, f -> acc + f.fee } + val price = if (totalCrypto.signum() > 0) { + totalFiat.divide(totalCrypto, 2, java.math.RoundingMode.HALF_UP) + } else { + fills.first().price + } + val ref = fills.first() + return HistoricalTrade( + orderId = orderId, + timestamp = fills.maxOf { it.timestamp }, + crypto = ref.crypto, + fiat = ref.fiat, + cryptoAmount = totalCrypto, + fiatAmount = totalFiat, + price = price, + fee = totalFee, + feeAsset = ref.feeAsset, + side = "BUY" + ) + } + private fun withinTolerance(actual: BigDecimal, expected: BigDecimal): Boolean { if (expected.signum() == 0) return true val ratio = actual.toDouble() / expected.toDouble() @@ -74,8 +111,8 @@ class ReconcileRecentBuyUseCase( } private companion object { - const val SETTLEMENT_ATTEMPTS = 2 - const val SETTLEMENT_DELAY_MS = 1_000L + const val SETTLEMENT_ATTEMPTS = 3 + const val SETTLEMENT_DELAY_MS = 2_000L const val LOOKBACK_BUFFER_SECONDS = 5L const val PAGE_LIMIT = 50 const val AMOUNT_TOLERANCE = 0.30 diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt index ddc2bc8..d168174 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt @@ -176,7 +176,7 @@ class HistoryViewModel @Inject constructor( val cryptosDeferred = async { transactionDao.getDistinctCryptos() } val exchangesDeferred = async { transactionDao.getDistinctExchanges() } val plansDeferred = async { - dcaPlanDao.getAllPlansOnce().map { plan -> + dcaPlanDao.getAllPlansOnceOrdered().map { plan -> HistoryPlanOption( id = plan.id, label = plan.name.ifBlank { "${plan.crypto}/${plan.fiat}" } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt b/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt index 8130a41..e17e877 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt @@ -250,9 +250,12 @@ class DcaWorker @AssistedInject constructor( var reconcileUncertain = false attemptLoop@ for (attempt in 1..maxAttempts) { - val attemptResult = withTimeoutOrNull(30_000L) { + // Kept strictly ABOVE OkHttp's callTimeout (30s) so this coroutine + // timeout only fires after OkHttp has already aborted the request - + // reconciliation must never run while the order POST is still in flight. + val attemptResult = withTimeoutOrNull(45_000L) { api.marketBuy(plan.crypto, plan.fiat, purchaseAmount) - } ?: DcaResult.Error("API call timed out after 30s", retryable = true) + } ?: DcaResult.Error("API call timed out after 45s", retryable = true) if (attemptResult is DcaResult.Success) { finalResult = attemptResult diff --git a/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCaseTest.kt b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCaseTest.kt index 87de475..1eaa005 100644 --- a/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCaseTest.kt +++ b/accbot-android/app/src/test/java/com/accbot/dca/domain/usecase/ReconcileRecentBuyUseCaseTest.kt @@ -120,4 +120,59 @@ class ReconcileRecentBuyUseCaseTest { assertEquals(ReconcileResult.NotFound, result) } + + @Test + fun `aggregates partial fills of one order before matching the amount`() = runTest { + // One market buy filled across two rows of 25 each = 50 total. + val api = FakeExchangeApi(tradeHistoryHandler = { _, _, _, _ -> + TradeHistoryPage( + listOf( + buyTrade("split", since.plusSeconds(5), fiat = BigDecimal("25")), + buyTrade("split", since.plusSeconds(7), fiat = BigDecimal("25")) + ), + hasMore = false + ) + }) + + val result = useCase(api, plan(), since, expectedFiat = BigDecimal("50")) + + assertTrue(result is ReconcileResult.Found) + val trade = (result as ReconcileResult.Found).trade + assertEquals("split", trade.orderId) + assertEquals(0, trade.fiatAmount.compareTo(BigDecimal("50"))) + } + + @Test + fun `tolerates exchange clock skew within the lookback buffer`() = runTest { + // Order stamped slightly before attemptStart (device clock ahead of exchange). + val api = FakeExchangeApi(tradeHistoryHandler = { _, _, _, _ -> + TradeHistoryPage(listOf(buyTrade("skew", since.minusSeconds(2))), hasMore = false) + }) + + val result = useCase(api, plan(), since, expectedFiat = BigDecimal("50")) + + assertTrue(result is ReconcileResult.Found) + } + + @Test + fun `excludes orders already recorded under another plan on the same connection`() = runTest { + val p = plan() // connectionId 6 + // Another plan on the SAME connection already recorded this order. + db.transactionDao().insertTransaction( + TransactionEntity( + planId = 99, exchange = Exchange.COINMATE, connectionId = 6, + crypto = "BTC", fiat = "CZK", fiatAmount = BigDecimal("50"), + cryptoAmount = BigDecimal("0.00003"), price = BigDecimal("1500000"), + fee = BigDecimal("0.17"), status = TransactionStatus.COMPLETED, + exchangeOrderId = "shared", executedAt = since.plusSeconds(10) + ) + ) + val api = FakeExchangeApi(tradeHistoryHandler = { _, _, _, _ -> + TradeHistoryPage(listOf(buyTrade("shared", since.plusSeconds(10))), hasMore = false) + }) + + val result = useCase(api, p, since, expectedFiat = BigDecimal("50")) + + assertEquals(ReconcileResult.NotFound, result) + } } From cff5973584ef70ad273ed84280a0ff30956ecf04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 12 Jun 2026 14:51:14 +0200 Subject: [PATCH 72/75] fix(dca): close remaining duplicate-buy vectors (transport retry, force runs) - disable OkHttp retryOnConnectionFailure: silent transparent POST re-send on stale pooled connections could place two real orders below the worker's reconcile logic - serialize all forceRun executions (runNow/runPlan/runMissedPurchases) through one unique-work queue (APPEND_OR_REPLACE) - forced runs bypass the per-plan claim, so they must never run concurrently - apply the runaway circuit breaker to forced runs too, with a wider allowance (+repeatCount) for user-initiated catch-ups - checkpoint catch-up progress in missedPurchaseCount (decrement per completed buy) so a worker replayed after process death resumes instead of re-buying the whole batch - forced runs return failure instead of retry on unexpected errors (no automatic replay of user-initiated buys) - alarm work: KEEP instead of REPLACE so a re-fired alarm cannot cancel a worker mid-buy Co-Authored-By: Claude Fable 5 --- .../java/com/accbot/dca/data/local/Daos.kt | 3 + .../main/java/com/accbot/dca/di/AppModule.kt | 7 ++ .../screens/DashboardViewModel.kt | 8 +- .../java/com/accbot/dca/worker/DcaWorker.kt | 82 +++++++++++++------ 4 files changed, 72 insertions(+), 28 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt index 2ea65e6..3813a07 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt @@ -125,6 +125,9 @@ interface DcaPlanDao { @Query("UPDATE dca_plans SET missedPurchaseCount = 0 WHERE id = :planId") suspend fun resetMissedPurchaseCount(planId: Long) + @Query("UPDATE dca_plans SET missedPurchaseCount = MAX(missedPurchaseCount - 1, 0) WHERE id = :planId") + suspend fun decrementMissedPurchaseCount(planId: Long) + @Query("UPDATE dca_plans SET name = :name WHERE id = :planId") suspend fun renamePlan(planId: Long, name: String) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt b/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt index 244f6e8..75ad4ec 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt @@ -136,6 +136,13 @@ object AppModule { // first - the worker must never start reconciliation while the order POST is // still on the wire (that would risk a false "not found" and a duplicate buy). .callTimeout(30, TimeUnit.SECONDS) + // OkHttp's default (true) silently re-sends a request - including a POST - + // when a pooled connection turns out to be stale or a route fails. For a + // non-idempotent order POST that is a duplicate-buy vector BELOW the app's + // reconcile logic (the worker never sees the first attempt). Disabled: + // such failures surface as IOException -> retryable -> the worker + // reconciles against the exchange before ever re-issuing the buy. + .retryOnConnectionFailure(false) .build() } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt index 65d7328..c530f53 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt @@ -697,10 +697,10 @@ class DashboardViewModel @Inject constructor( missedPurchases = it.missedPurchases.filter { m -> m.planId != planId } ) } - viewModelScope.launch { - dcaPlanDao.resetMissedPurchaseCount(planId) - DcaWorker.runMissedPurchases(application, planId, count) - } + // missedPurchaseCount is NOT reset here - the worker consumes it as a persisted + // checkpoint (one decrement per completed catch-up buy), so a worker replayed + // after process death resumes instead of re-buying from the start. + DcaWorker.runMissedPurchases(application, planId, count) } fun dismissMissedPurchases(planId: Long) { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt b/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt index e17e877..0d5b1b2 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt @@ -66,8 +66,19 @@ class DcaWorker @AssistedInject constructor( Log.w(TAG, "Failed to resolve pending transactions", e) } + // Catch-up progress is persisted in missedPurchaseCount so that a replayed worker + // (process death mid-loop -> WorkManager re-runs the whole request) resumes where + // it left off instead of re-buying already-covered slots from iteration 1. + val isCatchUp = forceRun && forcePlanId > 0 && repeatCount > 1 + val totalIterations = if (isCatchUp) { + val remaining = database.dcaPlanDao().getPlanById(forcePlanId)?.missedPurchaseCount ?: 0 + minOf(repeatCount, remaining) + } else { + repeatCount + } + try { - for (iteration in 1..repeatCount) { + for (iteration in 1..totalIterations) { if (iteration > 1) { Log.d(TAG, "Repeat iteration $iteration/$repeatCount") kotlinx.coroutines.delay(3_000L) // brief pause between missed purchases @@ -202,25 +213,27 @@ class DcaWorker @AssistedInject constructor( // Circuit breaker: if this plan has already bought far more than its schedule // allows in the last 24h, something is wrong - auto-disable instead of continuing // to spend. Counts reconciled buys too, so it reflects real exchange spend. - // Skipped for forceRun (user-initiated catch-up is intentional). - if (!forceRun) { - val buysLast24h = database.transactionDao() - .countCompletedBuysSinceSync(plan.id, now.minus(Duration.ofHours(24))) - val expectedPerDay = BuySafetyPolicy.expectedBuysPerDay(effectiveIntervalMinutes(plan)) - if (BuySafetyPolicy.isRunaway(buysLast24h, expectedPerDay)) { - Log.e(TAG, "Plan ${plan.id} runaway detected ($buysLast24h buys/24h, expected ~$expectedPerDay) - auto-disabling") - database.dcaPlanDao().setEnabled(plan.id, false) - notificationService.showErrorNotification( - planId = plan.id, - exchange = plan.exchange, - connectionId = plan.connectionId, - templateArgs = NotificationTemplateArgs.Error( - crypto = plan.crypto, - errorMessage = context.getString(R.string.notification_runaway_disabled) - ) + // Forced runs (run-now / catch-up) are user-initiated and may legitimately + // exceed the schedule, so they get a wider allowance (+repeatCount) instead + // of bypassing the breaker - a replayed or duplicated force run must still + // be bounded. + val buysLast24h = database.transactionDao() + .countCompletedBuysSinceSync(plan.id, now.minus(Duration.ofHours(24))) + val expectedPerDay = BuySafetyPolicy.expectedBuysPerDay(effectiveIntervalMinutes(plan)) + val allowedPerDay = if (forceRun) expectedPerDay + repeatCount else expectedPerDay + if (BuySafetyPolicy.isRunaway(buysLast24h, allowedPerDay)) { + Log.e(TAG, "Plan ${plan.id} runaway detected ($buysLast24h buys/24h, allowed ~$allowedPerDay) - auto-disabling") + database.dcaPlanDao().setEnabled(plan.id, false) + notificationService.showErrorNotification( + planId = plan.id, + exchange = plan.exchange, + connectionId = plan.connectionId, + templateArgs = NotificationTemplateArgs.Error( + crypto = plan.crypto, + errorMessage = context.getString(R.string.notification_runaway_disabled) ) - continue - } + ) + continue } // Atomically claim the plan to prevent double-purchase from concurrent workers. @@ -508,6 +521,12 @@ class DcaWorker @AssistedInject constructor( } } } + + // Consume one persisted catch-up slot per finished iteration (see isCatchUp + // above) so a replayed worker continues instead of starting over. + if (isCatchUp) { + database.dcaPlanDao().decrementMissedPurchaseCount(forcePlanId) + } } // repeat loop // Re-arm alarm for next execution (self-perpetuating chain) @@ -525,7 +544,11 @@ class DcaWorker @AssistedInject constructor( notificationService.showErrorNotification(context.getString(R.string.notification_dca_error), e.message ?: "Unknown error") // Still try to re-arm alarm even on error try { DcaAlarmScheduler.scheduleNextAlarm(context) } catch (_: Exception) {} - return Result.retry() + // Forced runs bypass the per-plan claim and due-time guards, so an automatic + // WorkManager retry could re-buy plans that already bought in this run. They + // are user-initiated - fail instead; the user sees the error notification + // and can trigger the run again. + return if (forceRun) Result.failure() else Result.retry() } } @@ -614,6 +637,14 @@ class DcaWorker @AssistedInject constructor( private const val KEY_REPEAT_COUNT = "repeatCount" const val WORK_NAME = "dca_periodic_work" + /** + * Single unique-work queue for ALL user-initiated (forceRun) executions. + * Forced runs bypass the per-plan claim, so they must never run concurrently - + * APPEND_OR_REPLACE serializes them one after another (and replaces a failed + * chain instead of blocking future runs). + */ + private const val FORCE_WORK_NAME = "dca_force_work" + /** * Plan IDs we've already shown a "missing credentials" notification for in * this process lifetime. Prevents spamming the notification tray on every @@ -672,7 +703,7 @@ class DcaWorker @AssistedInject constructor( .build() WorkManager.getInstance(context) - .enqueue(oneTimeWorkRequest) + .enqueueUniqueWork(FORCE_WORK_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, oneTimeWorkRequest) Log.d(TAG, "DCA one-time work enqueued (forceRun=true)") } @@ -696,7 +727,7 @@ class DcaWorker @AssistedInject constructor( .build() WorkManager.getInstance(context) - .enqueue(oneTimeWorkRequest) + .enqueueUniqueWork(FORCE_WORK_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, oneTimeWorkRequest) Log.d(TAG, "DCA one-time work enqueued for plan $planId (forceRun=true)") } @@ -721,7 +752,7 @@ class DcaWorker @AssistedInject constructor( .build() WorkManager.getInstance(context) - .enqueue(oneTimeWorkRequest) + .enqueueUniqueWork(FORCE_WORK_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, oneTimeWorkRequest) Log.d(TAG, "Missed purchases enqueued for plan $planId (count=$count)") } @@ -749,7 +780,10 @@ class DcaWorker @AssistedInject constructor( .build() WorkManager.getInstance(context) - .enqueueUniqueWork(ALARM_WORK_NAME, ExistingWorkPolicy.REPLACE, oneTimeWorkRequest) + // KEEP, never REPLACE: a re-fired alarm must not cancel a worker that may + // be mid-buy - REPLACE could abort it after the order POST was sent, + // leaving a real order unrecorded (invisible to the runaway breaker). + .enqueueUniqueWork(ALARM_WORK_NAME, ExistingWorkPolicy.KEEP, oneTimeWorkRequest) Log.d(TAG, "DCA alarm-triggered work enqueued (unique=$ALARM_WORK_NAME)") } From 81aa97b05f96b2083d5a09efe9330cef4db072ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 12 Jun 2026 14:51:32 +0200 Subject: [PATCH 73/75] fix(exchange): Kraken market buy - replace removed 'viqc' flag with base-volume sizing Kraken removed the volume-in-quote (viqc) oflag from the spot API, so every buy failed. Size the order in base currency from the current ticker price, reserving the taker fee so cost + fee stays within the plan's fiat amount. A failed price fetch returns a retryable error (no order was placed). Co-Authored-By: Claude Fable 5 --- .../com/accbot/dca/exchange/OtherExchanges.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt index 34f005a..dde2d1d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt @@ -109,7 +109,21 @@ class KrakenApi( withContext(Dispatchers.IO) { try { val pair = mapPair(crypto, fiat) - val params = "ordertype=market&type=buy&pair=$pair&oflags=viqc&volume=${fiatAmount.toPlainString()}" + // Kraken's AddOrder takes volume in BASE currency - the 'viqc' + // (volume-in-quote) flag was removed from the spot API, so we size the + // order from the current price and reserve the taker fee so that + // cost + fee stays within the plan's fiat amount. + val price = getCurrentPrice(crypto, fiat) + ?: return@withContext DcaResult.Error( + "Could not fetch $pair price to size the order", + retryable = true + ) + val volume = fiatAmount.divide( + price.multiply(BigDecimal.ONE.plus(estimatedTakerFeeRate)), + 8, + RoundingMode.DOWN + ) + val params = "ordertype=market&type=buy&pair=$pair&volume=${volume.toPlainString()}" val (isSuccessful, code, body) = executePrivateRequest("/0/private/AddOrder", params) From 19977e283d0fe3e67fbee371ef0d2970b055b064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 12 Jun 2026 14:51:33 +0200 Subject: [PATCH 74/75] fix(security): biometric lock - close process-death bypass, re-lock after background grace - isUnlocked uses plain remember: rememberSaveable survived process death and restored the unlocked state, silently skipping the lock when the task was reopened from recents - re-lock when the app spends more than 30s in background (lifecycle observer with monotonic clock); quick app switches don't re-prompt Co-Authored-By: Claude Fable 5 --- .../main/java/com/accbot/dca/MainActivity.kt | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt b/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt index 56bfd6e..bdee31a 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt @@ -7,6 +7,7 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.os.PowerManager +import android.os.SystemClock import android.provider.Settings import androidx.appcompat.app.AppCompatActivity import androidx.activity.compose.setContent @@ -71,9 +72,14 @@ import com.accbot.dca.service.NotificationService import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.lifecycleScope import javax.inject.Inject +/** How long the app may stay in background before the biometric lock re-engages. */ +private const val BIOMETRIC_RELOCK_GRACE_MS = 30_000L + @AndroidEntryPoint class MainActivity : AppCompatActivity() { @@ -112,8 +118,35 @@ class MainActivity : AppCompatActivity() { setContent { val isSandboxMode = userPreferences.isSandboxMode() - var isUnlocked by rememberSaveable { mutableStateOf(false) } + // Plain remember, NOT rememberSaveable: saved instance state survives process + // death (system kills the app in background, task stays in recents), so a + // saveable flag would restore isUnlocked=true and silently skip the lock. + var isUnlocked by remember { mutableStateOf(false) } val biometricEnabled = userPreferences.isBiometricLockEnabled() + + // Re-lock when the app spends longer than a short grace period in background, + // so a quick app switch doesn't re-prompt but a real walk-away does. + if (biometricEnabled) { + DisposableEffect(Unit) { + var backgroundedAt = 0L + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_STOP -> + backgroundedAt = SystemClock.elapsedRealtime() + Lifecycle.Event.ON_START -> { + if (backgroundedAt != 0L && + SystemClock.elapsedRealtime() - backgroundedAt > BIOMETRIC_RELOCK_GRACE_MS + ) { + isUnlocked = false + } + } + else -> {} + } + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + } // Theme: collect reactive flow so changes apply immediately val appTheme by userPreferences.appThemeFlow.collectAsState() val darkTheme = when (appTheme) { From a34b350c9fd9cd1bf8e2d919a282abf450f64599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 12 Jun 2026 14:51:33 +0200 Subject: [PATCH 75/75] refactor(chart): remove BUY/SELL trade-marker triangles from portfolio chart Visual clutter, and with many transactions the markers were aggregated into meaningless clusters. Removes the feature end-to-end: decoration + data class (ChartComponents), marker computation and completedSells cache (ViewModel), call sites (Screen). Chart recreation key no longer depends on markers. Co-Authored-By: Claude Fable 5 --- .../components/ChartComponents.kt | 103 +----------------- .../screens/portfolio/PortfolioScreen.kt | 2 - .../screens/portfolio/PortfolioViewModel.kt | 38 +------ 3 files changed, 4 insertions(+), 139 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt index 97b18c7..dc0e898 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt @@ -51,10 +51,8 @@ import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.core.cartesian.Zoom import com.patrykandpatrick.vico.core.cartesian.marker.DefaultCartesianMarker import com.patrykandpatrick.vico.core.cartesian.marker.LineCartesianLayerMarkerTarget -import com.accbot.dca.domain.model.TransactionSide import com.accbot.dca.presentation.utils.NumberFormatters import java.math.BigDecimal -import java.time.Instant import java.time.LocalDate import java.time.format.DateTimeFormatter import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisGuidelineComponent @@ -62,7 +60,6 @@ import com.patrykandpatrick.vico.compose.cartesian.marker.rememberShowOnPress import com.patrykandpatrick.vico.compose.common.component.rememberShapeComponent import com.patrykandpatrick.vico.compose.common.component.rememberLineComponent import com.patrykandpatrick.vico.core.cartesian.data.CartesianLayerRangeProvider -import com.patrykandpatrick.vico.core.cartesian.decoration.Decoration import com.patrykandpatrick.vico.core.cartesian.decoration.HorizontalLine import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerController import com.patrykandpatrick.vico.core.common.data.ExtraStore @@ -70,7 +67,6 @@ import com.patrykandpatrick.vico.core.common.shape.CorneredShape import com.patrykandpatrick.vico.core.common.shape.DashedShape import com.patrykandpatrick.vico.core.common.shape.Shape import androidx.compose.ui.graphics.toArgb -import java.time.ZoneId private val chartAccentColor = Primary private val costBasisColor = Color(0xFF888888) @@ -232,68 +228,6 @@ private fun LegendItem(color: Color, label: String, enabled: Boolean = true, onC } } -/** - * BUY/SELL transaction marker for the portfolio chart timeline. - * - * Rendered by [TradeMarkersDecoration] as a small triangle at the bottom (BUY, - * up-triangle, success color) or top (SELL, down-triangle, error color) of the - * plot area, x-aligned with the chart bucket that contains the trade. - */ -data class ChartTradeMarker( - val time: Instant, - val side: TransactionSide -) - -/** - * Custom Vico [Decoration] that renders BUY/SELL trade markers as triangles - * pinned to the plot area edges, aligned with chart-bucket indices. - * - * Coordinate math: each [ChartDataPoint] is at integer model x = its array - * index, so a marker for chart index i lives at model x = i.toDouble(). The - * pixel x is derived from layerBounds + layerDimensions.startPadding plus the - * scaled offset relative to ranges.minX, minus the current scroll. - */ -private class TradeMarkersDecoration( - private val markers: List>, - private val buyColorArgb: Int, - private val sellColorArgb: Int, - private val sizeDp: Float = 8f -) : Decoration { - - private val paint = android.graphics.Paint().apply { - isAntiAlias = true - style = android.graphics.Paint.Style.FILL - } - - override fun drawOverLayers(context: com.patrykandpatrick.vico.core.cartesian.CartesianDrawingContext) { - if (markers.isEmpty()) return - val bounds = context.layerBounds - val dims = context.layerDimensions - val ranges = context.ranges - val sizePx = context.dpToPx(sizeDp) - val halfSize = sizePx / 2f - val scroll = context.scroll - - for ((x, side) in markers) { - val modelDelta = x - ranges.minX - val pxX = bounds.left + dims.startPadding + (modelDelta * dims.xSpacing).toFloat() - scroll - if (pxX < bounds.left - halfSize || pxX > bounds.right + halfSize) continue - val (color, apexY, baseY) = when (side) { - TransactionSide.BUY -> Triple(buyColorArgb, bounds.bottom - sizePx, bounds.bottom) - TransactionSide.SELL -> Triple(sellColorArgb, bounds.top + sizePx, bounds.top) - } - paint.color = color - val path = android.graphics.Path().apply { - moveTo(pxX, apexY) - lineTo(pxX - halfSize, baseY) - lineTo(pxX + halfSize, baseY) - close() - } - context.canvas.drawPath(path, paint) - } - } -} - /** * Portfolio line chart with dual Y-axis support. * Left axis (start): portfolio value, cost basis, crypto price (all in fiat). @@ -314,12 +248,6 @@ fun PortfolioLineChart( visibleCryptoGroupLines: Set> = emptySet(), zoomLevel: ChartZoomLevel = ChartZoomLevel.Overview, onScrub: (Int?) -> Unit = {}, - /** - * Optional BUY (green up-triangle) / SELL (red down-triangle) markers to render - * on the chart timeline. Markers are bucketed to the chart point whose epochDay - * is the floor of the trade's epochDay. - */ - tradeMarkers: List = emptyList(), /** * Limit prices of currently open (PENDING / PARTIAL) sell orders for the * displayed plan. Each value yields a horizontal line on the left (fiat) axis @@ -623,33 +551,6 @@ fun PortfolioLineChart( } } - // Trade markers: convert each ChartTradeMarker into a chart-x model index by - // finding the chart bucket whose epochDay floors the trade's epochDay. - val buyMarkerColor = accumulatedCryptoColor.toArgb() - val sellMarkerColor = MaterialTheme.colorScheme.error.toArgb() - val tradeMarkerPoints = remember(tradeMarkers, chartData) { - if (tradeMarkers.isEmpty() || chartData.isEmpty()) emptyList() - else { - val zone = ZoneId.systemDefault() - val firstDay = chartData.first().epochDay - val lastDay = chartData.last().epochDay - tradeMarkers.mapNotNull { m -> - val txDay = m.time.atZone(zone).toLocalDate().toEpochDay() - if (txDay < firstDay || txDay > lastDay) return@mapNotNull null - // Floor: largest index whose epochDay <= txDay. - val idx = chartData.indexOfLast { it.epochDay <= txDay } - if (idx < 0) null else idx.toDouble() to m.side - } - } - } - val tradeMarkerDecoration = remember(tradeMarkerPoints, buyMarkerColor, sellMarkerColor) { - if (tradeMarkerPoints.isEmpty()) null - else TradeMarkersDecoration(tradeMarkerPoints, buyMarkerColor, sellMarkerColor) - } - val allDecorations = remember(sellDecorations, tradeMarkerDecoration) { - sellDecorations + listOfNotNull(tradeMarkerDecoration) - } - // Y-axis range provider: when there are open-sell limit lines, expand the // auto-calculated Y range (left/fiat axis) so all limit prices remain visible // even when they sit far above/below the actual portfolio/price series. @@ -726,7 +627,7 @@ fun PortfolioLineChart( } } ) { - key(openSellLimitPrices, tradeMarkerPoints) { + key(openSellLimitPrices) { CartesianChartHost( chart = rememberCartesianChart( rememberLineCartesianLayer( @@ -765,7 +666,7 @@ fun PortfolioLineChart( ), marker = marker, markerController = CartesianMarkerController.rememberShowOnPress(), - decorations = allDecorations + decorations = sellDecorations ), modelProducer = modelProducer, scrollState = rememberVicoScrollState(scrollEnabled = false), diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index b88fd28..5326ea8 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -174,8 +174,6 @@ fun PortfolioScreen( openSellLimitPrices = if (uiState.currentPlanAllowsSells && uiState.denominationMode == DenominationMode.FIAT && uiState.limitLinesVisible) openSellLimitPrices else emptyList(), - tradeMarkers = if (uiState.denominationMode == DenominationMode.FIAT) - uiState.tradeMarkers else emptyList(), modifier = Modifier .fillMaxWidth() .weight(1f) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt index e93d151..a81f412 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt @@ -109,11 +109,6 @@ data class PortfolioUiState( * horizontal lines on the per-plan page. */ val currentPlanAllowsSells: Boolean = false, - /** - * Completed BUY/SELL transactions for the currently selected page, mapped to - * (executedAt, side). Drives the chart timeline triangle markers. - */ - val tradeMarkers: List = emptyList() ) @HiltViewModel @@ -166,8 +161,6 @@ class PortfolioViewModel @Inject constructor( private var openSellsJob: Job? = null private var completedTransactions: List = emptyList() - /** Cache of completed SELL transactions for chart-marker rendering only. */ - private var completedSells: List = emptyList() /** * Cached plan list used by [loadChartData] to build aggregate per-plan lines. * Refreshed by [loadPortfolio] and [refreshTransactionsAndPairs]. Without this @@ -214,7 +207,6 @@ class PortfolioViewModel @Inject constructor( // Use pre-filtered, sorted query (avoids loading failed/pending into memory) val completed = transactionDao.getCompletedTransactionsOrdered() completedTransactions = completed - completedSells = transactionDao.getCompletedSellsOrdered() // Load all plans (including disabled) for page building val allDbPlans = dcaPlanDao.getAllPlansOnceOrdered() @@ -290,7 +282,6 @@ class PortfolioViewModel @Inject constructor( try { val completed = transactionDao.getCompletedTransactionsOrdered() completedTransactions = completed - completedSells = transactionDao.getCompletedSellsOrdered() val allDbPlans = dcaPlanDao.getAllPlansOnceOrdered() cachedDbPlans = allDbPlans @@ -650,7 +641,6 @@ class PortfolioViewModel @Inject constructor( totalTransactions = chartResult.txCount, planLines = chartResult.planLines, cryptoGroupLines = chartResult.cryptoGroupLines, - tradeMarkers = chartResult.tradeMarkers, isChartLoading = false ) } @@ -707,8 +697,7 @@ class PortfolioViewModel @Inject constructor( val fiat: String?, val txCount: Int, val planLines: List, - val cryptoGroupLines: List, - val tradeMarkers: List + val cryptoGroupLines: List ) private suspend fun computeChartData(): ChartComputeResult { @@ -833,36 +822,13 @@ class PortfolioViewModel @Inject constructor( (fiat == null || tx.fiat == fiat) } - // Build trade markers from BUY (filteredTxs already pair/plan-scoped) + SELL. - val sellsForPage = if (planId != null) { - completedSells.filter { it.planId == planId } - } else { - completedSells.filter { (crypto == null || it.crypto == crypto) && (fiat == null || it.fiat == fiat) } - } - val buysForMarkers = filteredTxs.filter { - (crypto == null || it.crypto == crypto) && (fiat == null || it.fiat == fiat) - } - val markers = buildList { - buysForMarkers.forEach { add( - com.accbot.dca.presentation.components.ChartTradeMarker( - time = it.executedAt, side = com.accbot.dca.domain.model.TransactionSide.BUY - ) - ) } - sellsForPage.forEach { add( - com.accbot.dca.presentation.components.ChartTradeMarker( - time = it.executedAt, side = com.accbot.dca.domain.model.TransactionSide.SELL - ) - ) } - } - return ChartComputeResult( data = data, crypto = crypto, fiat = fiat, txCount = txCount, planLines = planLinesList, - cryptoGroupLines = cryptoGroupLinesList, - tradeMarkers = markers + cryptoGroupLines = cryptoGroupLinesList ) }