diff --git a/go.mod b/go.mod index e326cd99c..ad85581aa 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/crypto-power/cryptopower go 1.21 require ( - decred.org/dcrdex v0.6.3 + decred.org/dcrdex v1.0.0 decred.org/dcrwallet/v4 v4.1.3 gioui.org v0.7.1 git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 @@ -225,4 +225,4 @@ require ( replace github.com/lib/pq => github.com/lib/pq v1.10.4 // https://github.com/ukane-philemon/dcrdex/tree/btc-node -replace decred.org/dcrdex v0.6.3 => github.com/ukane-philemon/dcrdex v0.0.0-20240906090529-912997266ecf +replace decred.org/dcrdex v1.0.0 => github.com/ukane-philemon/dcrdex v0.0.0-20240906090529-912997266ecf diff --git a/ui/cryptomaterial/dropdown.go b/ui/cryptomaterial/dropdown.go index c336795f8..99ac3114e 100644 --- a/ui/cryptomaterial/dropdown.go +++ b/ui/cryptomaterial/dropdown.go @@ -323,6 +323,10 @@ func (d *DropDown) collapsedAndExpandedLayout(gtx C) D { func (d *DropDown) expandedLayout(gtx C) D { m := op.Record(gtx.Ops) gtx.Constraints.Min.Y = gtx.Constraints.Max.Y + // This allows the dropdown to over lap other elements on the screen and not + // limit the dropdown items to the height of its parent + // (gtx.Constraints.Max.Y). + gtx.Constraints.Max.Y = inf d.updateDropdownWidth(gtx, true) d.updateDropdownBackground(true) d.ExpandedLayoutInset.Layout(gtx, func(gtx C) D { diff --git a/ui/page/dcrdex/dcrdex_page.go b/ui/page/dcrdex/dcrdex_page.go index eadf3cb02..698fe46d0 100644 --- a/ui/page/dcrdex/dcrdex_page.go +++ b/ui/page/dcrdex/dcrdex_page.go @@ -103,7 +103,7 @@ func (pg *DEXPage) prepareInitialPage() { } if showOnBoardingPage { - pg.Display(NewDEXOnboarding(pg.Load, "")) + pg.Display(NewDEXOnboarding(pg.Load, "", nil)) } else { pg.Display(NewDEXMarketPage(pg.Load, "")) } diff --git a/ui/page/dcrdex/dex_onboarding_page.go b/ui/page/dcrdex/dex_onboarding_page.go index f3bc03e0c..c666a2a3c 100644 --- a/ui/page/dcrdex/dex_onboarding_page.go +++ b/ui/page/dcrdex/dex_onboarding_page.go @@ -121,10 +121,13 @@ type DEXOnboarding struct { bondServer *bondServerInfo // Sub Step Add Server - wantCustomServer bool - serverURLEditor cryptomaterial.Editor - serverCertEditor cryptomaterial.Editor - goBackToChooseServer *cryptomaterial.Clickable + wantCustomServer bool + serverURLEditor cryptomaterial.Editor + serverCertEditor cryptomaterial.Editor + // goBackToChooseServerOrMarketPage redirects a user back to the choose + // server screen if dex has not initialized and no previously registered dex + // server is found, else the user is redirect to the markets page. + goBackToChooseServerOrMarketPage *cryptomaterial.Clickable // TODO: add a file selector to choose server cert. // Step Post Bond @@ -140,9 +143,10 @@ type DEXOnboarding struct { goBackBtn cryptomaterial.Button nextBtn cryptomaterial.Button - materialLoader material.LoaderStyle - isLoading bool - existingDEXServer string + materialLoader material.LoaderStyle + isLoading bool + existingDEXServer string + redirectToMarketPageFn func() bondFeeCache map[uint32]uint64 } @@ -150,27 +154,28 @@ type DEXOnboarding struct { // NewDEXOnboarding creates a new DEX onboarding pages. Specify // existingDEXServer to use the DEX onboarding flow to allow user post bonds for // a particular server. -func NewDEXOnboarding(l *load.Load, existingDEXServer string) *DEXOnboarding { +func NewDEXOnboarding(l *load.Load, existingDEXServer string, redirectToMarketPageFn func()) *DEXOnboarding { th := l.Theme pg := &DEXOnboarding{ - Load: l, - GenericPageModal: app.NewGenericPageModal(DEXOnboardingPageID), - scrollContainer: &widget.List{List: layout.List{Axis: layout.Vertical, Alignment: layout.Middle}}, - passwordEditor: newPasswordEditor(th, values.String(values.StrNewPassword)), - confirmPasswordEditor: newPasswordEditor(th, values.String(values.StrConfirmPassword)), - seedEditor: newTextEditor(l.Theme, values.String(values.StrOptionalRestorationSeed), values.String(values.StrOptionalRestorationSeed), true), - addServerBtn: th.NewClickable(false), - bondServer: &bondServerInfo{}, - serverURLEditor: newTextEditor(th, values.String(values.StrServerURL), values.String(values.StrInputURL), false), - serverCertEditor: newTextEditor(th, values.String(values.StrCertificateOPtional), values.String(values.StrInputCertificate), true), - goBackToChooseServer: th.NewClickable(false), - bondStrengthEditor: newTextEditor(th, values.String(values.StrBondStrength), "1", false), - bondStrengthMoreInfo: th.NewClickable(false), - goBackBtn: th.Button(values.String(values.StrBack)), - nextBtn: th.Button(values.String(values.StrNext)), - materialLoader: material.Loader(th.Base), - existingDEXServer: existingDEXServer, - bondFeeCache: make(map[uint32]uint64), + Load: l, + GenericPageModal: app.NewGenericPageModal(DEXOnboardingPageID), + scrollContainer: &widget.List{List: layout.List{Axis: layout.Vertical, Alignment: layout.Middle}}, + passwordEditor: newPasswordEditor(th, values.String(values.StrNewPassword)), + confirmPasswordEditor: newPasswordEditor(th, values.String(values.StrConfirmPassword)), + seedEditor: newTextEditor(l.Theme, values.String(values.StrOptionalRestorationSeed), values.String(values.StrOptionalRestorationSeed), true), + addServerBtn: th.NewClickable(false), + bondServer: &bondServerInfo{}, + serverURLEditor: newTextEditor(th, values.String(values.StrServerURL), values.String(values.StrInputURL), false), + serverCertEditor: newTextEditor(th, values.String(values.StrCertificateOPtional), values.String(values.StrInputCertificate), true), + goBackToChooseServerOrMarketPage: th.NewClickable(false), + bondStrengthEditor: newTextEditor(th, values.String(values.StrBondStrength), "1", false), + bondStrengthMoreInfo: th.NewClickable(false), + goBackBtn: th.Button(values.String(values.StrBack)), + nextBtn: th.Button(values.String(values.StrNext)), + materialLoader: material.Loader(th.Base), + existingDEXServer: existingDEXServer, + bondFeeCache: make(map[uint32]uint64), + redirectToMarketPageFn: redirectToMarketPageFn, } pg.onBoardingSteps = map[onboardingStep]dexOnboardingStep{ @@ -260,7 +265,6 @@ func (pg *DEXOnboarding) OnNavigatedFrom() { // to be eventually drawn on screen. // Part of the load.Page interface. func (pg *DEXOnboarding) Layout(gtx C) D { - pg.handleEditorEvents(gtx) if !pg.AssetsManager.DEXCInitialized() { pg.ParentNavigator().CloseCurrentPage() return D{} @@ -463,7 +467,7 @@ func (pg *DEXOnboarding) subStepAddServer(gtx C) D { Alignment: layout.Middle, }.Layout(gtx, layout.Rigid(func(gtx C) D { - if !pg.wantCustomServer { + if !pg.wantCustomServer && pg.redirectToMarketPageFn == nil { return D{} } @@ -471,7 +475,7 @@ func (pg *DEXOnboarding) subStepAddServer(gtx C) D { Width: cryptomaterial.WrapContent, Height: cryptomaterial.WrapContent, Orientation: layout.Horizontal, - Clickable: pg.goBackToChooseServer, + Clickable: pg.goBackToChooseServerOrMarketPage, }.Layout(gtx, layout.Rigid(func(gtx C) D { return pg.Theme.Icons.NavigationArrowBack.Layout(gtx, pg.Theme.Color.Gray1) @@ -1047,6 +1051,8 @@ func (pg *DEXOnboarding) handleEditorEvents(gtx C) { pg.isLoading = false }() } + + pg.ParentWindow().Reload() } } @@ -1064,9 +1070,13 @@ func (pg *DEXOnboarding) HandleUserInteractions(gtx C) { pg.serverCertEditor.Editor.SetText("") } - if pg.goBackToChooseServer.Clicked(gtx) { + if pg.goBackToChooseServerOrMarketPage.Clicked(gtx) { pg.wantCustomServer = false - pg.currentStep = onboardingChooseServer + if pg.redirectToMarketPageFn != nil { + pg.redirectToMarketPageFn() + } else { + pg.currentStep = onboardingChooseServer + } pg.serverURLEditor.SetError("") pg.serverCertEditor.SetError("") } @@ -1105,6 +1115,8 @@ func (pg *DEXOnboarding) HandleUserInteractions(gtx C) { if pg.bondSourceAccountSelector != nil { pg.bondSourceAccountSelector.Handle(gtx) } + + pg.handleEditorEvents(gtx) } func (pg *DEXOnboarding) setAddServerStep() { diff --git a/ui/page/dcrdex/market.go b/ui/page/dcrdex/market.go index 396469a37..b322128b1 100644 --- a/ui/page/dcrdex/market.go +++ b/ui/page/dcrdex/market.go @@ -96,10 +96,16 @@ type DEXMarketPage struct { toggleBuyAndSellBtn *cryptomaterial.SegmentedControl orderTypesDropdown *cryptomaterial.DropDown - priceEditor cryptomaterial.Editor + priceEditor cryptomaterial.Editor + // TODO: Remove switchLotsOrAmount and related checks for amounts input on + // lotsOrAmountEditor. It seems we prefer users learning how to trade with + // lots since it's more straight forward. If we intend to allow users + // provide an amount and convert to lots for them before this todo is done, + // we can just just display this switch instead of the lot size. switchLotsOrAmount *cryptomaterial.Switch lotsOrAmountEditor cryptomaterial.Editor totalEditor cryptomaterial.Editor + lotsInfoBtn *cryptomaterial.Clickable maxBuyOrSellStr string orderFeeEstimateStr string @@ -155,6 +161,7 @@ func NewDEXMarketPage(l *load.Load, selectServer string) *DEXMarketPage { priceEditor: newTextEditor(l.Theme, values.String(values.StrPrice), "", false), switchLotsOrAmount: l.Theme.Switch(), lotsOrAmountEditor: newTextEditor(l.Theme, values.String(values.StrLots), "", false), + lotsInfoBtn: th.NewClickable(false), totalEditor: newTextEditor(th, values.String(values.StrTotal), "", false), maxBuyOrSellStr: "---", orderFeeEstimateStr: "------", @@ -779,10 +786,10 @@ func (pg *DEXMarketPage) orderForm(gtx C) D { } else { if sell { // Show base asset available balance. tradeDirection = values.String(values.StrSell) - availableAssetBal, baseOrQuoteAssetSym = pg.availableWalletAccountBalanceString(false) + availableAssetBal, baseOrQuoteAssetSym = pg.availableWalletAccountBalance(false) } else { tradeDirection = values.String(values.StrBuy) - availableAssetBal, baseOrQuoteAssetSym = pg.availableWalletAccountBalanceString(true) + availableAssetBal, baseOrQuoteAssetSym = pg.availableWalletAccountBalance(true) } } @@ -820,15 +827,28 @@ func (pg *DEXMarketPage) orderForm(gtx C) D { layout.Rigid(func(gtx C) D { return layout.Inset{Bottom: dp5}.Layout(gtx, func(gtx C) D { var labelText string + var lotSize string if pg.orderWithLots() { labelText = fmt.Sprintf("%s (%s)", values.String(values.StrLots), lotsOrAmountSubtext) + if mkt := pg.selectedMarketInfo(); mkt != nil { + lotSize = values.StringF(values.StrLotSizeFmt, fmt.Sprintf("%s %s", trimmedConventionalAmtString(mkt.MsgRateToConventional(mkt.LotSize)), convertAssetIDToAssetType(pg.selectedMarketOrderBook.base))) + } } else { labelText = fmt.Sprintf("%s (%s)", values.String(values.StrAmount), lotsOrAmountSubtext) } return layout.Flex{Axis: horizontal}.Layout(gtx, layout.Rigid(pg.semiBoldLabelText(labelText).Layout), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: dp5, Left: dp2}.Layout(gtx, func(gtx C) D { + return pg.lotsInfoBtn.Layout(gtx, pg.Theme.Icons.InfoAction.Layout16dp) + }) + }), layout.Flexed(1, func(gtx C) D { - return layout.E.Layout(gtx, pg.switchLotsOrAmount.Layout) + if lotSize == "" { + return D{} + } + + return layout.E.Layout(gtx, pg.Theme.Label(values.TextSize14, lotSize).Layout) }), ) }) @@ -998,10 +1018,9 @@ func trimZeros(s string) string { return strings.TrimSuffix(strings.TrimRight(s, "0"), ".") } -// availableWalletAccountBalanceString returns the balance of the DEX wallet -// account for the quote or base asset of the selected market. Returns the -// wallet's spendable balance as string. -func (pg *DEXMarketPage) availableWalletAccountBalanceString(forQuoteAsset bool) (bal float64, assetSym string) { +// availableWalletAccountBalance returns the balance of the DEX wallet account +// for the quote or base asset of the selected market. +func (pg *DEXMarketPage) availableWalletAccountBalance(forQuoteAsset bool) (bal float64, assetSym string) { if pg.noMarketOrServerDisconnected.Load() { return 0, "" } @@ -1348,6 +1367,8 @@ func (pg *DEXMarketPage) setBuyOrSell() { pg.lotsOrAmountEditor.UpdateFocus(!pg.lotsOrAmountEditor.Editor.ReadOnly) pg.totalEditor.Editor.ReadOnly = isSell pg.totalEditor.UpdateFocus(!pg.totalEditor.Editor.ReadOnly) + pg.lotsOrAmountEditor.Editor.SetText("") + pg.totalEditor.Editor.SetText("") if !isSell { // Buy pg.createOrderBtn.Text = values.String(values.StrBuy) @@ -1474,7 +1495,7 @@ func (pg *DEXMarketPage) HandleUserInteractions(gtx C) { selectedServer := pg.serverSelector.Selected() xc, err := dexc.Exchange(selectedServer) if err != nil && xc.Auth.EffectiveTier == 0 /* need to post bond now */ { - pg.ParentNavigator().ClearStackAndDisplay(NewDEXOnboarding(pg.Load, selectedServer)) + pg.ParentNavigator().ClearStackAndDisplay(NewDEXOnboarding(pg.Load, selectedServer, nil)) } else { pg.lastSelectedDEXServer = selectedServer pg.setServerMarkets() @@ -1482,7 +1503,9 @@ func (pg *DEXMarketPage) HandleUserInteractions(gtx C) { } if pg.addServerBtn.Clicked(gtx) { - pg.ParentNavigator().ClearStackAndDisplay(NewDEXOnboarding(pg.Load, "")) + pg.ParentNavigator().ClearStackAndDisplay(NewDEXOnboarding(pg.Load, "", func() { + pg.ParentNavigator().ClearStackAndDisplay(NewDEXMarketPage(pg.Load, "")) + })) } if pg.openOrdersBtn.Clicked(gtx) { @@ -1506,6 +1529,18 @@ func (pg *DEXMarketPage) HandleUserInteractions(gtx C) { log.Info("button click listener for full order book view is not implemented") } + if pg.lotsInfoBtn.Clicked(gtx) { + infoModal := modal.NewCustomModal(pg.Load). + Title(values.String(values.StrLots)). + UseCustomWidget(func(gtx layout.Context) layout.Dimensions { + return pg.Theme.Body2(values.String(values.StrLotsExplanation)).Layout(gtx) + }). + SetCancelable(true). + SetContentAlignment(layout.W, layout.W, layout.Center). + SetPositiveButtonText(values.String(values.StrOk)) + pg.ParentWindow().ShowModal(infoModal) + } + if pg.immediateOrderInfoBtn.Clicked(gtx) { infoModal := modal.NewCustomModal(pg.Load). Title(values.String(values.StrImmediateOrder)). @@ -1520,7 +1555,7 @@ func (pg *DEXMarketPage) HandleUserInteractions(gtx C) { // TODO: postBondBtn should open a separate page when its design is ready. if pg.postBondBtn.Clicked(gtx) { - pg.ParentNavigator().ClearStackAndDisplay(NewDEXOnboarding(pg.Load, pg.serverSelector.Selected())) + pg.ParentNavigator().ClearStackAndDisplay(NewDEXOnboarding(pg.Load, pg.serverSelector.Selected(), nil)) } if pg.loginBtn.Clicked(gtx) { @@ -1827,9 +1862,16 @@ func anyMatchActive(matches []*core.Match) bool { func (pg *DEXMarketPage) hasValidOrderInfo() bool { mkt := pg.selectedMarketInfo() _, lotsOrAmtOk := pg.orderLotsOrAmt() - _, totalOk := pg.totalOrderAmt() + orderAmt, totalOk := pg.totalOrderAmt() // TODO: Check that their tier limit has not been exceeded by this trade. - return pg.orderPrice(mkt) > 0 && lotsOrAmtOk && totalOk + orderPriceIsOk := pg.orderPrice(mkt) > 0 && lotsOrAmtOk && totalOk + if !orderPriceIsOk { + return false + } + + // Fetch wallet balance from dex and ensure wallet can fund dex order. + walletBalance, _ := pg.availableWalletAccountBalance(!pg.isSellOrder()) + return orderPriceIsOk && orderAmt < walletBalance } func (pg *DEXMarketPage) orderLotsOrAmt() (float64, bool) { @@ -1867,6 +1909,7 @@ func (pg *DEXMarketPage) calculateOrderAmount(mkt *core.Market, isSwitchLotsOrAm if !pg.isSellOrder() { amtStr := pg.totalEditor.Editor.Text() if amtStr == "" { + pg.lotsOrAmountEditor.Editor.SetText("") return false } @@ -1874,6 +1917,7 @@ func (pg *DEXMarketPage) calculateOrderAmount(mkt *core.Market, isSwitchLotsOrAm // we calculate based on that. totalAmt, err := strconv.ParseFloat(amtStr, 64) if err != nil || totalAmt <= 0 { + pg.lotsOrAmountEditor.Editor.SetText("") pg.totalEditor.SetError(values.String(values.StrInvalidAmount)) return false } @@ -1898,7 +1942,7 @@ func (pg *DEXMarketPage) calculateOrderAmount(mkt *core.Market, isSwitchLotsOrAm pg.totalEditor.Editor.SetText("") if pg.orderWithLots() { - if lots, err := strconv.ParseFloat(lotsOrAmtStr, 64); err != nil || lots <= 0 { + if lots, err := strconv.ParseFloat(lotsOrAmtStr, 64); err != nil || lots <= 0 || float64(int64(lots)) != lots { pg.lotsOrAmountEditor.SetError(values.String(values.StrInvalidLot)) } else { if isSwitchLotsOrAmtChanged { diff --git a/ui/values/localizable/en.go b/ui/values/localizable/en.go index ae8d78dbe..86e25f17c 100644 --- a/ui/values/localizable/en.go +++ b/ui/values/localizable/en.go @@ -847,8 +847,10 @@ const EN = ` "24hVolume" = "24h Volume (%s)" "24hHigh" = "24h High" "lots" = "Lots" +"lotSizeFmt" = "1 Lot = %v" +"lotsExplanation" = "The Lot size is the minimum amount you can buy or sell in one trade. Lot sizes are chosen to minimize the on chain fees to below ~1% of each trade value. If you want to buy or sell 10 (or any number) lots, total order quantity will be -> (number of lots * lots size) denominated in the base currency." "invalidPrice" = "Invalid price" -"invalidLot" = "Invalid lot" +"invalidLot" = "Invalid lot: Lot must be a valid non-decimal number" "minMaxLot" = "Min Lots: %d, Max Lots: %d" "buy" = "Buy" "sell" = "Sell" diff --git a/ui/values/strings.go b/ui/values/strings.go index 5aa3b8ed0..0de231fe0 100644 --- a/ui/values/strings.go +++ b/ui/values/strings.go @@ -958,6 +958,8 @@ const ( Str24hVolume = "24hVolume" Str24hHigh = "24hHigh" StrLots = "lots" + StrLotSizeFmt = "lotSizeFmt" + StrLotsExplanation = "lotsExplanation" StrInvalidLot = "invalidLot" StrInvalidPrice = "invalidPrice" StrBuy = "buy"