From 1f71883fa4e3ca9d25da4fdd4a6140795226c4d6 Mon Sep 17 00:00:00 2001 From: Ulises Manuel <90291778+ulisesmac@users.noreply.github.com> Date: Thu, 26 Oct 2023 16:07:44 -0600 Subject: [PATCH] [#16755] - Network routing component (#17457) * Add network routing component & animations * Add basic tests and accessibility labels --- .../wallet/network_routing/animation.cljs | 115 +++++++++++ .../wallet/network_routing/style.cljs | 79 ++++++++ .../wallet/network_routing/view.cljs | 189 ++++++++++++++++++ src/quo/core.cljs | 2 + .../network_routing/component_spec.cljs | 28 +++ src/status_im2/contexts/quo_preview/main.cljs | 2 + .../quo_preview/wallet/network_routing.cljs | 94 +++++++++ 7 files changed, 509 insertions(+) create mode 100644 src/quo/components/wallet/network_routing/animation.cljs create mode 100644 src/quo/components/wallet/network_routing/style.cljs create mode 100644 src/quo/components/wallet/network_routing/view.cljs create mode 100644 src/quo2/components/wallet/network_routing/component_spec.cljs create mode 100644 src/status_im2/contexts/quo_preview/wallet/network_routing.cljs diff --git a/src/quo/components/wallet/network_routing/animation.cljs b/src/quo/components/wallet/network_routing/animation.cljs new file mode 100644 index 0000000000..7dab4c8019 --- /dev/null +++ b/src/quo/components/wallet/network_routing/animation.cljs @@ -0,0 +1,115 @@ +(ns quo.components.wallet.network-routing.animation + (:require [react-native.reanimated :as reanimated])) + +(def ^:private slider-timing 300) + +(defn show-slider + [opacity-shared-value] + (reanimated/animate opacity-shared-value 1 slider-timing)) + +(defn hide-slider + [opacity-shared-value] + (reanimated/animate opacity-shared-value 0 slider-timing)) + +(defn increase-slider + [width-shared-value height-shared-value] + (reanimated/animate width-shared-value 8 slider-timing) + (reanimated/animate height-shared-value 40 slider-timing)) + +(defn decrease-slider + [width-shared-value height-shared-value] + (reanimated/animate width-shared-value 4 slider-timing) + (reanimated/animate height-shared-value 32 slider-timing)) + +(def ^:private pressed-bar-timing 600) + +(defn move-previous-bars + [{:keys [bars bars-widths-negative]}] + (doseq [[bar-idx bar] (map-indexed vector bars) + :let [new-translation-x (->> (take (inc bar-idx) bars-widths-negative) + (reduce +) + (dec))]] + (reanimated/animate (:translate-x-shared-value bar) + new-translation-x + pressed-bar-timing))) + +(defn move-pressed-bar + [{:keys [bars-widths-negative number-previous-bars] + {:keys [translate-x-shared-value]} :bar}] + (let [new-translation-x (reduce + (take number-previous-bars bars-widths-negative))] + (reanimated/animate translate-x-shared-value + new-translation-x + (- pressed-bar-timing 20)))) + +(defn move-next-bars + [{:keys [bars bars-widths-negative number-previous-bars extra-offset add-new-timeout]}] + (doseq [[bar-idx bar] (map-indexed vector bars) + :let [number-bars-before (+ number-previous-bars + (inc bar-idx)) + new-translation-x (->> (take number-bars-before bars-widths-negative) + (reduce +) + (* 1.05))]] + (reanimated/animate (:translate-x-shared-value bar) new-translation-x pressed-bar-timing) + (add-new-timeout + (keyword (str "fix-next-bars-position-" bar-idx)) + (fn [] + (let [translate-x-value (reanimated/get-shared-value (:translate-x-shared-value bar)) + hidden-position (- translate-x-value extra-offset)] + (reanimated/set-shared-value (:translate-x-shared-value bar) hidden-position))) + pressed-bar-timing))) + +(def ^:private max-limit-bar-timing 300) + +(defn show-max-limit-bar + [max-limit-bar-opacity] + (reanimated/animate max-limit-bar-opacity 1 max-limit-bar-timing)) + +(defn hide-max-limit-bar + [max-limit-bar-opacity] + (reanimated/animate max-limit-bar-opacity 0 max-limit-bar-timing)) + +(defn reset-bars-positions + [bars unlock-press-fn add-new-timeout] + (let [bars-reset-timing 500] + (doseq [{:keys [translate-x-shared-value]} bars] + (reanimated/animate translate-x-shared-value 0 bars-reset-timing)) + (add-new-timeout :unlock-press unlock-press-fn bars-reset-timing))) + +(defn align-bars-off-screen + [{:keys [new-network-values network-bars amount->width add-new-timeout]}] + (let [width-to-off-screen (->> new-network-values + (reduce #(- %1 (:amount %2)) 0) + (amount->width))] + (doseq [[bar-idx {new-amount :amount} bar] (map vector (range) new-network-values network-bars)] + (reanimated/set-shared-value (:amount-shared-value bar) new-amount) + (reanimated/set-shared-value (:translate-x-shared-value bar) (* 2 width-to-off-screen)) + (add-new-timeout + (keyword (str "align-bar-" bar-idx)) + #(reanimated/set-shared-value (:translate-x-shared-value bar) width-to-off-screen) + 1)))) + +(def ^:private hide-bar-timing 400) + +(defn hide-pressed-bar + [{:keys [translate-x-shared-value amount-shared-value]} amount->width] + (let [bar-width (amount->width (reanimated/get-shared-value amount-shared-value)) + new-translation-x (- (reanimated/get-shared-value translate-x-shared-value) + bar-width)] + (reanimated/animate translate-x-shared-value new-translation-x hide-bar-timing))) + +(defn update-bar-values-and-reset-animations + [{:keys [new-network-values network-bars amount->width reset-values-fn add-new-timeout + lock-press-fn unlock-press-fn]}] + (lock-press-fn) + (add-new-timeout + :update-bars-values + (fn [] + (align-bars-off-screen {:new-network-values new-network-values + :network-bars network-bars + :amount->width amount->width + :add-new-timeout add-new-timeout}) + (reset-values-fn) + (add-new-timeout :reset-bars + #(reset-bars-positions network-bars unlock-press-fn add-new-timeout) + 100)) + hide-bar-timing)) diff --git a/src/quo/components/wallet/network_routing/style.cljs b/src/quo/components/wallet/network_routing/style.cljs new file mode 100644 index 0000000000..b7f08dcdfd --- /dev/null +++ b/src/quo/components/wallet/network_routing/style.cljs @@ -0,0 +1,79 @@ +(ns quo.components.wallet.network-routing.style + (:require [quo.foundations.colors :as colors] + [react-native.reanimated :as reanimated])) + +(defn container + [container-style theme] + (assoc container-style + :flex-direction :row + :height 64 + :background-color (colors/theme-colors colors/neutral-100-opa-5 colors/neutral-90 theme) + :border-radius 20 + :overflow :hidden)) + +(defn max-limit-bar + [{:keys [opacity-shared-value width]}] + (reanimated/apply-animations-to-style + {:opacity opacity-shared-value} + {:position :absolute + :top 0 + :bottom 0 + :left 0 + :width width + :z-index -1 + :flex-direction :row})) + +(defn max-limit-bar-background + [network-name] + {:flex 1 + :background-color (colors/resolve-color network-name nil 10)}) + +(defn network-bar + [{:keys [max-width on-top? bar-division? theme] + {:keys [network-name translate-x-shared-value]} :bar} + width-shared-value] + (reanimated/apply-animations-to-style + {:width width-shared-value + :transform [{:translate-x translate-x-shared-value}]} + {:max-width max-width + :flex-direction :row + :justify-content :flex-end + :background-color (colors/resolve-color network-name nil) + :z-index (if on-top? 1 0) + :border-right-width (if bar-division? 0 1) + :border-color (colors/theme-colors colors/white colors/neutral-95 theme)})) + +(def slider-container + {:width 40 + :background-color :transparent + :justify-content :center + :align-items :center + :right -20}) + +(def ^:private slider-fixed-styles + {:background-color colors/white + :height 32 + :width 4 + :border-radius 4}) + +(defn slider + [{:keys [width-shared-value height-shared-value opacity-shared-value]}] + (reanimated/apply-animations-to-style + {:width width-shared-value + :height height-shared-value + :opacity opacity-shared-value} + slider-fixed-styles)) + +(def dashed-line + {:width 1 + :height "100%" + :margin-left -1 + :margin-top -1.5}) + +(defn dashed-line-line + [network-name] + {:background-color (colors/resolve-color network-name nil) + :height 3 + :width 1}) + +(def dashed-line-space {:height 4 :width 1}) diff --git a/src/quo/components/wallet/network_routing/view.cljs b/src/quo/components/wallet/network_routing/view.cljs new file mode 100644 index 0000000000..1f52df2065 --- /dev/null +++ b/src/quo/components/wallet/network_routing/view.cljs @@ -0,0 +1,189 @@ +(ns quo.components.wallet.network-routing.view + (:require + [oops.core :as oops] + [quo.components.wallet.network-routing.animation :as animation] + [quo.components.wallet.network-routing.style :as style] + [quo.theme :as quo.theme] + [react-native.core :as rn] + [react-native.gesture :as gesture] + [react-native.reanimated :as reanimated] + [reagent.core :as reagent] + [utils.number])) + +(def ^:private timeouts (atom {})) + +(defn- add-new-timeout + [k f ms] + (letfn [(exec-fn-and-remove-timeout [] + (f) + (swap! timeouts dissoc k))] + (js/clearTimeout (k @timeouts)) + (swap! timeouts assoc k (js/setTimeout exec-fn-and-remove-timeout ms)))) + +(defn- f-slider + [slider-shared-values] + [rn/view {:style style/slider-container} + [reanimated/view {:style (style/slider slider-shared-values)}]]) + +(defn f-network-bar + [_] + (let [detecting-gesture? (reagent/atom false) + amount-on-gesture-start (atom 0)] + (fn [{:keys [total-width total-amount on-press on-new-amount allow-press?] + {:keys [amount-shared-value + max-amount]} :bar + :as props}] + (let [slider-width-shared-value (reanimated/use-shared-value 4) + slider-height-shared-value (reanimated/use-shared-value 32) + slider-opacity-shared-value (reanimated/use-shared-value 0) + network-bar-shared-value (reanimated/interpolate amount-shared-value + [0 total-amount] + [0 total-width]) + width->amount #(/ (* % total-amount) total-width)] + [rn/touchable-without-feedback + {:on-press (fn [] + (when (and (not @detecting-gesture?) allow-press?) + (on-press) + (reset! detecting-gesture? true) + (animation/show-slider slider-opacity-shared-value)))} + [reanimated/view + {:style (style/network-bar props network-bar-shared-value) + :accessibility-label :network-routing-bar} + [gesture/gesture-detector + {:gesture + (-> (gesture/gesture-pan) + (gesture/enabled @detecting-gesture?) + (gesture/on-begin + (fn [_] + (animation/increase-slider slider-width-shared-value slider-height-shared-value) + (reset! amount-on-gesture-start (reanimated/get-shared-value amount-shared-value)))) + (gesture/on-update + (fn [event] + (let [new-amount (-> (oops/oget event "translationX") + (width->amount) + (+ @amount-on-gesture-start) + (utils.number/value-in-range 1 max-amount))] + (reanimated/set-shared-value amount-shared-value new-amount)))) + (gesture/on-finalize + (fn [_] + (animation/decrease-slider slider-width-shared-value slider-height-shared-value) + (animation/hide-slider slider-opacity-shared-value) + (on-new-amount (reanimated/get-shared-value amount-shared-value)) + (add-new-timeout :turn-off-gesture #(reset! detecting-gesture? false) 20))))} + [:f> f-slider + {:width-shared-value slider-width-shared-value + :height-shared-value slider-height-shared-value + :opacity-shared-value slider-opacity-shared-value}]]]])))) + +(defn- add-bar-shared-values + [{:keys [amount] :as network}] + (assoc network + :amount-shared-value (reanimated/use-shared-value amount) + :translate-x-shared-value (reanimated/use-shared-value 0))) + +(def ^:private get-negative-amount + (comp - reanimated/get-shared-value :amount-shared-value)) + +(defn- dashed-line + [network-name] + [rn/view {:style style/dashed-line} + (take 19 + (interleave (repeat [rn/view {:style (style/dashed-line-line network-name)}]) + (repeat [rn/view {:style style/dashed-line-space}])))]) + +(defn f-network-routing-bars + [_] + (let [selected-network-idx (reagent/atom nil) + press-locked? (reagent/atom false) + lock-press #(reset! press-locked? true) + unlock-press #(reset! press-locked? false) + reset-state-values #(reset! selected-network-idx nil)] + (fn [{:keys [networks total-width total-amount requesting-data? on-amount-selected]}] + (let [bar-opacity-shared-value (reanimated/use-shared-value 0) + network-bars (map add-bar-shared-values networks) + amount->width #(* % (/ total-width total-amount)) + bars-widths-negative (map #(-> % get-negative-amount amount->width) + network-bars) + last-bar-idx (dec (count network-bars))] + (rn/use-effect + #(when (and (not requesting-data?) @selected-network-idx) + (let [bar (nth network-bars @selected-network-idx)] + (animation/hide-pressed-bar bar amount->width)) + (animation/update-bar-values-and-reset-animations + {:new-network-values networks + :network-bars network-bars + :amount->width amount->width + :reset-values-fn reset-state-values + :lock-press-fn lock-press + :unlock-press-fn unlock-press + :add-new-timeout add-new-timeout})) + [requesting-data?]) + [:<> + (doall + (for [[bar-idx bar] (map-indexed vector network-bars) + :let [bar-max-width (amount->width (:max-amount bar)) + bar-width (-> (:amount-shared-value bar) + (reanimated/get-shared-value) + (amount->width)) + hide-division? (or (= last-bar-idx bar-idx) @selected-network-idx) + this-bar-selected? (= @selected-network-idx bar-idx)]] + ^{:key (str "network-bar-" bar-idx)} + [:f> f-network-bar + {:bar bar + :max-width bar-max-width + :total-width total-width + :total-amount total-amount + :bar-division? hide-division? + :on-top? this-bar-selected? + :allow-press? (and (or (not @selected-network-idx) this-bar-selected?) + (not requesting-data?) + (not @press-locked?)) + :on-press (fn [] + (when-not @selected-network-idx + (let [[previous-bars [_ & next-bars]] (split-at bar-idx network-bars) + number-previous-bars bar-idx] + (animation/move-previous-bars + {:bars previous-bars + :bars-widths-negative bars-widths-negative}) + (animation/move-pressed-bar + {:bar bar + :bars-widths-negative bars-widths-negative + :number-previous-bars number-previous-bars}) + (animation/move-next-bars + {:bars next-bars + :bars-widths-negative bars-widths-negative + :number-previous-bars (inc number-previous-bars) + :extra-offset (max 0 (- bar-max-width bar-width)) + :add-new-timeout add-new-timeout})) + (animation/show-max-limit-bar bar-opacity-shared-value) + (reset! selected-network-idx bar-idx))) + :on-new-amount (fn [new-amount] + (animation/hide-max-limit-bar bar-opacity-shared-value) + (when on-amount-selected + (on-amount-selected new-amount @selected-network-idx)))}])) + + (let [{:keys [max-amount network-name]} (some->> @selected-network-idx + (nth network-bars)) + limit-bar-width (amount->width max-amount)] + [reanimated/view + {:style (style/max-limit-bar + {:opacity-shared-value bar-opacity-shared-value + :width limit-bar-width})} + [rn/view {:style (style/max-limit-bar-background network-name)}] + [dashed-line network-name]])])))) + +(defn view-internal + [{:keys [networks container-style theme] :as params}] + (reagent/with-let [total-width (reagent/atom nil)] + [rn/view + {:accessibility-label :network-routing + :style (style/container container-style theme) + :on-layout #(reset! total-width (oops/oget % "nativeEvent.layout.width"))} + (when @total-width + ^{:key (str "network-routing-" (count networks))} + [:f> f-network-routing-bars (assoc params :total-width @total-width)])] + (finally + (doseq [[_ living-timeout] @timeouts] + (js/clearTimeout living-timeout))))) + +(def view (quo.theme/with-theme view-internal)) diff --git a/src/quo/core.cljs b/src/quo/core.cljs index c4e4b0a4c5..0e22a0bb7b 100644 --- a/src/quo/core.cljs +++ b/src/quo/core.cljs @@ -141,6 +141,7 @@ quo.components.wallet.network-amount.view quo.components.wallet.network-bridge.view quo.components.wallet.network-link.view + quo.components.wallet.network-routing.view quo.components.wallet.progress-bar.view quo.components.wallet.summary-info.view quo.components.wallet.token-input.view @@ -373,6 +374,7 @@ (def keypair quo.components.wallet.keypair.view/view) (def network-amount quo.components.wallet.network-amount.view/view) (def network-bridge quo.components.wallet.network-bridge.view/view) +(def network-routing quo.components.wallet.network-routing.view/view) (def progress-bar quo.components.wallet.progress-bar.view/view) (def summary-info quo.components.wallet.summary-info.view/view) (def network-link quo.components.wallet.network-link.view/view) diff --git a/src/quo2/components/wallet/network_routing/component_spec.cljs b/src/quo2/components/wallet/network_routing/component_spec.cljs new file mode 100644 index 0000000000..18b2e87650 --- /dev/null +++ b/src/quo2/components/wallet/network_routing/component_spec.cljs @@ -0,0 +1,28 @@ +(ns quo2.components.wallet.network-routing.component-spec + (:require [oops.core :as oops] + [quo2.components.wallet.network-routing.view :as network-routing] + [reagent.core :as reagent] + [test-helpers.component :as h])) + +(h/describe "Network-routing tests" + (let [network {:amount 250 :max-amount 300 :network-name :unknown} + default-props {:networks [network network network] + :total-amount 500 + :requesting-data? false + :on-amount-selected (fn [_new-amount _network-idx] nil)}] + (h/test "Renders Default" + (h/render [network-routing/view default-props]) + (h/is-truthy (h/get-by-label-text :network-routing))) + + (h/test "Renders bars inside" + (let [component (h/render [network-routing/view default-props]) + rerender-fn #((oops/oget component "rerender") (reagent/as-element %)) + component (h/get-by-label-text :network-routing)] + ;; Fires on-layout callback since the total width is required + (h/fire-event :layout component #js {:nativeEvent #js {:layout #js {:width 1000}}}) + ;; Update props to trigger rerender, otherwise it won't be updated + (rerender-fn [network-routing/view (assoc default-props :requesting-data? true)]) + ;; Check number of networks rendered + (->> (js->clj (h/query-all-by-label-text :network-routing-bar)) + (count) + (h/is-equal 3)))))) diff --git a/src/status_im2/contexts/quo_preview/main.cljs b/src/status_im2/contexts/quo_preview/main.cljs index d42e823547..465b25045f 100644 --- a/src/status_im2/contexts/quo_preview/main.cljs +++ b/src/status_im2/contexts/quo_preview/main.cljs @@ -166,6 +166,7 @@ [status-im2.contexts.quo-preview.wallet.network-amount :as network-amount] [status-im2.contexts.quo-preview.wallet.network-bridge :as network-bridge] [status-im2.contexts.quo-preview.wallet.network-link :as network-link] + [status-im2.contexts.quo-preview.wallet.network-routing :as network-routing] [status-im2.contexts.quo-preview.wallet.progress-bar :as progress-bar] [status-im2.contexts.quo-preview.wallet.summary-info :as summary-info] [status-im2.contexts.quo-preview.wallet.token-input :as token-input] @@ -447,6 +448,7 @@ {:name :network-amount :component network-amount/preview} {:name :network-bridge :component network-bridge/preview} {:name :network-link :component network-link/preview} + {:name :network-routing :component network-routing/preview} {:name :progress-bar :component progress-bar/preview} {:name :summary-info :component summary-info/preview} {:name :token-input :component token-input/preview} diff --git a/src/status_im2/contexts/quo_preview/wallet/network_routing.cljs b/src/status_im2/contexts/quo_preview/wallet/network_routing.cljs new file mode 100644 index 0000000000..79263772cf --- /dev/null +++ b/src/status_im2/contexts/quo_preview/wallet/network_routing.cljs @@ -0,0 +1,94 @@ +(ns status-im2.contexts.quo-preview.wallet.network-routing + (:require [quo.core :as quo] + [quo.foundations.colors :as colors] + [react-native.core :as rn] + [reagent.core :as reagent] + [status-im2.contexts.quo-preview.preview :as preview])) + +(def descriptor + [{:label "Number of networks" + :key :number-networks + :type :select + :options [{:key 2} {:key 3} {:key 4} {:key 5}]}]) + +(defn- fake-call-to-get-amounts + [{:keys [new-amount fixed-index current-values on-success]}] + (let [number-networks (count current-values) + amount-difference (- (get current-values fixed-index) new-amount) + difference-distributed (/ amount-difference (dec number-networks)) + new-values (assoc (mapv #(+ % difference-distributed) current-values) + fixed-index + new-amount)] + (js/setTimeout #(on-success new-values) (rand-nth (range 700 5000 250))))) + +(defn preview-internal + [{:keys [total-amount number-networks] :as descriptor-state}] + (let [initial-amount (/ total-amount number-networks) + networks (reagent/atom + [{:amount initial-amount + :max-amount (descriptor-state :max-amount-0) + :network-name :ethereum} + {:amount initial-amount + :max-amount (descriptor-state :max-amount-1) + :network-name :arbitrum} + {:amount initial-amount + :max-amount (descriptor-state :max-amount-2) + :network-name :xDai} + {:amount initial-amount + :max-amount (descriptor-state :max-amount-3) + :network-name :optimism} + {:amount initial-amount + :max-amount (descriptor-state :max-amount-4) + :network-name :polygon}]) + requesting-data? (reagent/atom false)] + (fn [_] + (let [asked-networks (vec (take number-networks @networks)) + on-success-fn (fn [new-network-amounts] + (reset! requesting-data? false) + (swap! networks + #(map (fn [network new-amount] + (assoc network :amount new-amount)) + % + new-network-amounts)))] + [rn/view + [quo/network-routing + {:total-amount total-amount + :networks asked-networks + :requesting-data? @requesting-data? + :on-amount-selected (fn [new-amount selected-idx] + (reset! requesting-data? true) + (fake-call-to-get-amounts + {:new-amount new-amount + :fixed-index selected-idx + :current-values (mapv :amount asked-networks) + :on-success on-success-fn}))}] + (reduce (fn [acc {:keys [amount max-amount network-name]}] + (conj acc + [rn/view + {:style {:flex-direction :row + :margin-vertical 12}} + [rn/view + {:style {:background-color (colors/custom-color network-name) + :width 24 + :height 24 + :margin-right 12}}] + [quo/text + "Max limit: " max-amount " Amount: " (subs (str amount) 0 6)]])) + [rn/view {:style {:margin-vertical 12}} + [quo/text "Total amount: " (reduce + (map :amount asked-networks))]] + asked-networks)])))) + +(defn preview + [] + (let [descriptor-state (reagent/atom {:total-amount 400 + :number-networks 4 + :max-amount-0 350 + :max-amount-1 350 + :max-amount-2 300 + :max-amount-3 250 + :max-amount-4 200})] + (fn [] + [preview/preview-container {:state descriptor-state :descriptor descriptor} + [rn/view {:style {:flex 1 :margin-vertical 28}} + ^{:key (str "preview-network-routing-" (:number-networks @descriptor-state))} + [preview-internal @descriptor-state]]])))