From 7efb9aa0a7a53a620d65730aba11c8a46bc9c8da Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 19 May 2026 08:32:10 +0800 Subject: [PATCH] enhance(mobile): improve flashcards tab --- src/main/frontend/components/dnd.cljs | 13 +- src/main/frontend/extensions/fsrs.cljs | 185 +++++++++++++++-------- src/main/frontend/handler/events/ui.cljs | 2 +- src/main/frontend/spec/storage.cljc | 4 +- src/main/mobile/bottom_tabs.cljs | 50 +++--- src/main/mobile/components/app.cljs | 12 ++ src/main/mobile/components/app.css | 44 ++++++ src/main/mobile/components/header.cljs | 94 +++++++++--- src/main/mobile/components/settings.cljs | 99 ++++++++++++ src/main/mobile/state.cljs | 11 ++ src/main/mobile/tabs.cljs | 82 ++++++++++ src/resources/dicts/en.edn | 1 + src/resources/dicts/zh-cn.edn | 1 + src/test/mobile/navigation_test.cljs | 12 ++ src/test/mobile/tabs_test.cljs | 75 +++++++++ 15 files changed, 568 insertions(+), 117 deletions(-) create mode 100644 src/main/mobile/tabs.cljs create mode 100644 src/test/mobile/tabs_test.cljs diff --git a/src/main/frontend/components/dnd.cljs b/src/main/frontend/components/dnd.cljs index b8ffbc23b1..965e223a2a 100644 --- a/src/main/frontend/components/dnd.cljs +++ b/src/main/frontend/components/dnd.cljs @@ -1,5 +1,5 @@ (ns frontend.components.dnd - (:require ["@dnd-kit/core" :refer [DndContext closestCenter MouseSensor useSensor useSensors]] + (:require ["@dnd-kit/core" :refer [DndContext closestCenter MouseSensor TouchSensor useSensor useSensors]] ["@dnd-kit/sortable" :refer [useSortable arrayMove SortableContext verticalListSortingStrategy horizontalListSortingStrategy] :as sortable] ["@dnd-kit/utilities" :refer [CSS]] [cljs-bean.core :as bean] @@ -41,14 +41,17 @@ (when (some #(nil? (:id %)) col*) (js/console.error "dnd-kit items without id") (prn :col col*)) - (let [col (filter :id col*) + (let [col (filterv :id col*) ids (mapv :id col) + ids-key (pr-str ids) items' (bean/->js ids) id->item (zipmap ids col) [items-state set-items] (rum/use-state items') - _ (hooks/use-effect! (fn [] (set-items items')) [col]) + _ (hooks/use-effect! (fn [] (set-items items')) [ids-key]) [_active-id set-active-id] (rum/use-state nil) - sensors (useSensors (useSensor MouseSensor (bean/->js {:activationConstraint {:distance 8}}))) + sensors (useSensors (useSensor MouseSensor (bean/->js {:activationConstraint {:distance 8}})) + (useSensor TouchSensor (bean/->js {:activationConstraint {:delay 120 + :tolerance 8}}))) dnd-opts {:sensors sensors :collisionDetection closestCenter :onDragStart (fn [^js event] @@ -56,7 +59,7 @@ (set-active-id (.-id ^js (.-active event))))) :onDragEnd (fn [^js event] (let [active-id (.-id ^js (.-active event)) - over-id (.-id ^js (.-over event))] + over-id (some-> ^js (.-over event) .-id)] (when active-id (when-not (= active-id over-id) (let [old-index (.indexOf ids active-id) diff --git a/src/main/frontend/extensions/fsrs.cljs b/src/main/frontend/extensions/fsrs.cljs index a940a820f4..6478550446 100644 --- a/src/main/frontend/extensions/fsrs.cljs +++ b/src/main/frontend/extensions/fsrs.cljs @@ -115,6 +115,22 @@ q)] (db-async/ + {:variant :outline + :auto-focus false + :size :sm + :id id + :class (str id " " class " " + (if mobile? + "!w-full !min-h-[48px] !px-4 !py-3 rounded-xl text-base justify-center " + "!px-2 !py-1 ") + "bg-primary/5 hover:bg-primary/10 border-primary opacity-90 hover:opacity-100 " bg-class) - :on-pointer-down (fn [e] (util/stop-propagation e)) - :on-click (fn [_e] (js/setTimeout #(on-click) 10))} + :on-pointer-down (fn [e] (util/stop-propagation e)) + :on-click (fn [_e] (js/setTimeout #(on-click) 10))} + (not mobile?) + (assoc :title (t :flashcard/shortcut-tooltip shortcut))) [:div.flex.flex-row.items-center.gap-1 [:span btn-text] - (when-not (util/sm-breakpoint?) + (when-not (or mobile? (util/sm-breakpoint?)) [:span.scale-90 (shui/shortcut shortcut)])]) - (when due [:div.text-sm.opacity-50 (util/human-time due {:ago? false})])])) + (when (and show-due? due) + [:div.text-sm.opacity-50 (util/human-time due {:ago? false})])])) (defn- has-cloze? [block] @@ -184,9 +209,10 @@ (t (rating-key rating))) (defn- rating-btns - [repo block *card-index *phase] + [repo block *card-index *phase opts] (let [block-id (:db/id block)] [:div.flex.flex-row.items-center.gap-8.flex-wrap + {:class (when (:mobile? opts) "ls-mobile-card-rating-buttons")} (mapv (fn [rating] (let [card-map (get-card-map block) @@ -194,26 +220,29 @@ (btn-with-shortcut {:btn-text (rating-label rating) :shortcut (rating->shortcut rating) :due due + :show-due? (not (:mobile? opts)) + :mobile? (:mobile? opts) :id (str "card-" (name rating)) :on-click #(do (repeat-card! repo block-id rating) (swap! *card-index inc) (reset! *phase :init))}))) ratings) - (shui/button - {:variant :ghost - :size :sm - :class "!px-0 text-muted-foreground !h-4" - :on-click (fn [e] - (shui/popup-show! (.-target e) - (fn [] - [:div.p-4.max-w-lg - (for [rating ratings] - ^{:key (name rating)} - [:dl - [:dt (t (rating-key rating))] - [:dd (t (rating-desc-key rating))]])]) - {:align "start"}))} - (ui/icon "info-circle"))])) + (when-not (:mobile? opts) + (shui/button + {:variant :ghost + :size :sm + :class "!px-0 text-muted-foreground !h-4" + :on-click (fn [e] + (shui/popup-show! (.-target e) + (fn [] + [:div.p-4.max-w-lg + (for [rating ratings] + ^{:key (name rating)} + [:dl + [:dt (t (rating-key rating))] + [:dd (t (rating-desc-key rating))]])]) + {:align "start"}))} + (ui/icon "info-circle")))])) (rum/defcs ^:private card-view < rum/reactive db-mixins/query {:will-mount (fn [state] @@ -222,13 +251,14 @@ (p/let [result (db-async/next-phase block-entity phase)] [:div.ls-card.content.flex.flex-col.overflow-y-auto.overflow-x-hidden + {:class (when (:mobile? opts) "ls-mobile-card")} [:div.mb-4.ml-2.opacity-70.text-sm (component-block/breadcrumb {} repo (:block/uuid block-entity) {})] (let [option (case phase @@ -241,6 +271,7 @@ :ignore-block-collapsed? true})] (component-block/blocks-container option [block-entity])) [:div.mt-8.pb-2 + {:class (when (:mobile? opts) "ls-mobile-card-actions")} (if (contains? #{:show-cloze :show-answer} next-phase) (btn-with-shortcut {:btn-text (case next-phase :show-answer @@ -250,11 +281,12 @@ :init (t :flashcard.review/hide-answers)) :shortcut "s" + :mobile? (:mobile? opts) :id "card-answers" :on-click #(swap! *phase (fn [phase] (phase->next-phase block-entity phase)))}) - [:div.flex.justify-center (rating-btns repo block-entity *card-index *phase)])]])))) + [:div.flex.justify-center (rating-btns repo block-entity *card-index *phase opts)])]])))) (declare update-due-cards-count) (rum/defcs ^:large-vars/cleanup-todo cards-view < rum/reactive @@ -263,7 +295,8 @@ {:init (fn [state] (let [*block-ids (atom nil) *loading? (atom nil) - cards-id (last (:rum/args state)) + [cards-id opts] (:rum/args state) + opts (or opts {}) *cards-list (atom [{:db/id :global :block/title (t :flashcard/all-cards)}]) repo (state/get-current-repo) @@ -290,13 +323,21 @@ (assoc state ::block-ids *block-ids ::cards-id (atom (or cards-id :global)) + ::opts opts ::loading? *loading? ::cards-list *cards-list))) :will-unmount (fn [state] + (let [opts (::opts state)] + (when-let [on-header-change (:on-header-change opts)] + (on-header-change nil)) + (when-let [on-selector-change (:on-selector-change opts)] + (on-selector-change nil))) (update-due-cards-count) state)} - [state _cards-id] + [state _cards-id _opts] (let [repo (state/get-current-repo) + opts (::opts state) + mobile? (:mobile? opts) *cards-id (::cards-id state) cards-id (rum/react *cards-id) *cards-list (::cards-list state) @@ -307,47 +348,59 @@ block-ids (rum/react *block-ids) loading? (rum/react (::loading? state)) *card-index (::card-index state) - *phase (atom :init)] + card-index (rum/react *card-index) + *phase (atom :init) + progress-label (str (min (inc card-index) (count block-ids)) "/" (count block-ids)) + select-card! (fn [v] + (reset! *cards-id v) + (let [cards-id' (when-not (global-cards-id? v) v)] + (p/let [result ( tab + (assoc :title (t (:title-key tab))) + (dissoc :title-key))) + +(defn selected-tab-ids + [] + (mobile-tabs/selected-tab-ids + (storage/get :ls-mobile-tabs) + {:flashcards? (state/enable-flashcards?)} + (mobile-tabs/max-main-tabs (mobile-util/native-iphone?)))) + (defn configure [] - (configure-tabs - (cond-> - [{:id "home" - :title (t :nav/home) - :systemImage "house" - :role "normal"} - {:id "graphs" - :title (t :mobile.tab/graphs) - :systemImage "app.background.dotted" - :role "normal"} - {:id "capture" - :title (t :mobile.tab/capture) - :systemImage "tray" - :role "normal"} - {:id "go to" - :title (t :mobile.tab/go-to) - :systemImage "square.stack.3d.down.right" - :role "normal"}] - (mobile-util/native-android?) - (conj {:id "search" - :title (t :nav/search) - :systemImage "search" - :role "search"})))) + (let [tabs (->> (mobile-tabs/tab-configs + (storage/get :ls-mobile-tabs) + {:flashcards? (state/enable-flashcards?)} + (mobile-tabs/max-main-tabs (mobile-util/native-iphone?))) + (mapv translated-tab))] + (configure-tabs + (cond-> tabs + (mobile-util/native-android?) + (conj {:id "search" + :title (t :nav/search) + :systemImage "search" + :role "search"}))))) diff --git a/src/main/mobile/components/app.cljs b/src/main/mobile/components/app.cljs index adfb8b444e..e0e98089b8 100644 --- a/src/main/mobile/components/app.cljs +++ b/src/main/mobile/components/app.cljs @@ -10,6 +10,7 @@ [frontend.handler.editor :as editor-handler] [frontend.handler.repo :as repo-handler] [frontend.handler.user :as user-handler] + [frontend.extensions.fsrs :as fsrs] [frontend.mobile.util :as mobile-util] [frontend.rum :as frum] [frontend.state :as state] @@ -247,6 +248,16 @@ [] (quick-add/quick-add)) +(rum/defc flashcards < + {:did-mount (fn [state] + (fsrs/update-due-cards-count) + state)} + [] + [:div.ls-mobile-flashcards + (fsrs/cards-view nil {:mobile? true + :on-header-change mobile-state/set-flashcards-header! + :on-selector-change mobile-state/set-flashcards-selector!})]) + (rum/defc other-page < rum/static [route-view tab route-match] (let [page-view? (= (get-in route-match [:data :name]) :page)] @@ -260,6 +271,7 @@ (graphs/page)) (= tab "go to") (favorites/favorites) (= tab "search") nil + (= tab "flashcards") (component-with-restoring (flashcards)) (= tab "capture") (component-with-restoring (capture))))])) (rum/defc main-content < rum/static diff --git a/src/main/mobile/components/app.css b/src/main/mobile/components/app.css index f754d23088..566db57dae 100644 --- a/src/main/mobile/components/app.css +++ b/src/main/mobile/components/app.css @@ -133,6 +133,50 @@ ul { @apply mx-8 p-0; } +.ls-mobile-flashcards { + @apply h-full flex flex-col px-1 py-2; + + #cards-modal { + @apply h-full gap-2; + } + + #cards-modal .ls-card { + @apply flex-1 border-0 bg-transparent px-0 py-2 shadow-none; + } + + #cards-modal .ls-card > .blocks-container { + @apply text-base; + } + + #cards-modal .ls-card.content.ml-2 { + @apply ml-0 flex-none; + } + + #cards-modal button { + @apply rounded-md; + } + + .ls-mobile-card { + @apply min-h-0; + } + + .ls-mobile-card > .mb-4 { + @apply ml-0 mb-3; + } + + .ls-mobile-card-actions { + @apply mt-4 pb-0; + } + + .ls-mobile-card-rating-buttons { + @apply grid grid-cols-2 gap-2 w-full; + } + + .ls-mobile-card-rating-buttons > div { + @apply w-full; + } +} + .ui__notifications { @apply fixed top-8 pointer-events-none w-full; diff --git a/src/main/mobile/components/header.cljs b/src/main/mobile/components/header.cljs index f12fe900fd..883d8b1b0e 100644 --- a/src/main/mobile/components/header.cljs +++ b/src/main/mobile/components/header.cljs @@ -34,7 +34,7 @@ (defonce native-top-bar-listener? (atom false)) (defonce native-top-bar-listener-version (atom nil)) (defonce *journal-calendar-open? (atom false)) -(def ^:private native-top-bar-listener-current-version :sync-upload-v2) +(def ^:private native-top-bar-listener-current-version :flashcards-title-selector-v1) (defn- open-journal-calendar! [] (when (compare-and-set! *journal-calendar-open? false true) @@ -132,9 +132,40 @@ {:on-click #(p/do! (shui/popup-hide!) (open-new-db-graph!))} - (t :mobile.header/create-graph))]) + (t :mobile.header/create-graph))]) {:default-height false})) +(defn- global-cards-id? + [cards-id] + (contains? #{:global "global"} cards-id)) + +(defn- same-cards-id? + [a b] + (or (= a b) + (and (global-cards-id? a) + (global-cards-id? b)))) + +(defn- open-flashcards-selector! + [] + (when-let [{:keys [cards cards-id select-card!]} @mobile-state/*flashcards-selector] + (ui-component/open-popup! + (fn [] + [:div.-mx-2 + (for [card cards + :let [card-id (:db/id card)]] + (ui/menu-link + {:key (str card-id) + :on-click (fn [] + (p/do! + (select-card! card-id) + (shui/popup-hide!)))} + [:span.text-lg.flex.items-center.justify-between.gap-3.w-full + [:span.min-w-0.truncate (:block/title card)] + (when (same-cards-id? cards-id card-id) + (shui/tabler-icon "check" {:class "text-primary flex-none" :size 20}))]))]) + {:title (t :flashcard/select-cards) + :default-height false}))) + (defn current-local-uploadable-graph [] (let [current-repo (state/get-current-repo)] @@ -152,7 +183,9 @@ (fn [^js e] (case (.-id e) "back" (js/history.back) - "title" (open-graph-switch!) + "title" (if (= @mobile-state/*tab "flashcards") + (open-flashcards-selector!) + (open-graph-switch!)) "calendar" (open-journal-calendar!) "capture" (do (state/clear-edit!) @@ -234,12 +267,42 @@ header (cond-> base left-buttons (assoc :leftButtons left-buttons) right-buttons (assoc :rightButtons right-buttons) - (and (= tab "home") (not route-view)) (assoc :titleClickable true))] + (and (contains? #{"home" "flashcards"} tab) (not route-view)) + (assoc :titleClickable true))] (.configure ^js mobile-util/native-top-bar (clj->js header))))) +(defn- flashcards-native-title + [{:keys [title progress]}] + (let [title (if (string/blank? title) + (t :nav/flashcards) + title)] + (string/trim (str title " ▾" + (when-not (string/blank? progress) + (str " " progress)))))) + +(defn- build-fallback-title + [current-repo tab flashcards-header] + (cond + (= tab "home") + (if current-repo + (db-conn/get-short-repo-name current-repo) + (t :graph.switch/select-prompt)) + + (= tab "search") + (t :nav/search) + + (= tab "graphs") + (t :mobile.tab/graphs) + + (= tab "flashcards") + (flashcards-native-title flashcards-header) + + :else + (string/capitalize tab))) + (rum/defc header-inner - [current-repo tab route-match] + [current-repo tab route-match flashcards-header] (let [route-name (get-in route-match [:data :name]) route-view (get-in route-match [:data :view]) route-id (get-in route-match [:parameters :path :name]) @@ -257,20 +320,7 @@ show-local-upload? (some? local-uploadable-graph) unpushed-block-update-count (:pending-local-ops detail-info) pending-asset-ops (:pending-asset-ops detail-info) - fallback-title (cond - (= tab "home") - (if current-repo - (db-conn/get-short-repo-name current-repo) - (t :graph.switch/select-prompt)) - - (= tab "search") - (t :nav/search) - - (= tab "graphs") - (t :mobile.tab/graphs) - - :else - (string/capitalize tab)) + fallback-title (build-fallback-title current-repo tab flashcards-header) sync-color (if (and online? (= :open rtc-state) (zero? unpushed-block-update-count) @@ -341,6 +391,8 @@ (rum/defc header < rum/reactive [current-repo tab] - (let [route-match (state/sub :route-match)] + (let [route-match (state/sub :route-match) + flashcards-header (rum/react mobile-state/*flashcards-header)] (header-inner current-repo tab - route-match))) + route-match + flashcards-header))) diff --git a/src/main/mobile/components/settings.cljs b/src/main/mobile/components/settings.cljs index 88b651c60c..29362bf227 100644 --- a/src/main/mobile/components/settings.cljs +++ b/src/main/mobile/components/settings.cljs @@ -2,11 +2,14 @@ "Mobile settings" (:require [clojure.string :as string] [frontend.common.missionary :as c.m] + [frontend.components.dnd :as dnd] [frontend.components.user.login :as login] [frontend.config :as config] [frontend.context.i18n :refer [t]] [frontend.handler.user :as user-handler] + [frontend.mobile.util :as mobile-util] [frontend.state :as state] + [frontend.storage :as storage] [frontend.ui :as ui] [frontend.util :as util] [frontend.version :as version] @@ -14,7 +17,9 @@ [logseq.shui.hooks :as hooks] [logseq.shui.ui :as shui] [missionary.core :as m] + [mobile.bottom-tabs :as bottom-tabs] [mobile.state :as mobile-state] + [mobile.tabs :as mobile-tabs] [promesa.core :as p] [rum.core :as rum])) @@ -111,6 +116,94 @@ (for [record records] [:li (str (:level record) " " (:message record))])])])) +(defn- persist-mobile-tabs! + [tab-ids] + (storage/set :ls-mobile-tabs tab-ids) + (when-not (contains? (set tab-ids) @mobile-state/*tab) + (mobile-state/set-tab! mobile-tabs/required-tab-id)) + (bottom-tabs/configure)) + +(defn- selected-mobile-tabs-label + [] + (let [available-tabs (mobile-tabs/available-tabs + {:flashcards? (state/enable-flashcards?)}) + available-tabs-by-id (zipmap (map :id available-tabs) available-tabs)] + (->> (bottom-tabs/selected-tab-ids) + (keep #(some-> (get available-tabs-by-id %) :title-key t)) + (string/join " · ")))) + +(defn- mobile-tab-picker-row + [{:keys [id title-key checked? disabled? sortable? toggle-tab! key]}] + [:label.flex.items-center.justify-between.gap-3.py-2 + {:key (or key id) + :class (util/classnames + [{:opacity-50 disabled?}])} + [:span.flex.items-center.gap-2.min-w-0 + [:span.text-muted-foreground + {:class (if sortable? "cursor-grab" "opacity-30")} + (shui/tabler-icon "grip-vertical" {:size 14})] + [:span.text-base.truncate (t title-key)]] + (shui/checkbox + {:checked checked? + :disabled disabled? + :on-checked-change #(toggle-tab! id %)})]) + +(rum/defc mobile-tabs-picker + [] + (let [[custom-tab-ids set-custom-tab-ids!] (hooks/use-state + (storage/get :ls-mobile-tabs)) + features {:flashcards? (state/enable-flashcards?)} + max-tabs (mobile-tabs/max-main-tabs (mobile-util/native-iphone?)) + selected-tab-ids (mobile-tabs/selected-tab-ids custom-tab-ids features max-tabs) + selected-tab-id-set (set selected-tab-ids) + available-tabs (mobile-tabs/available-tabs features) + available-tabs-by-id (zipmap (map :id available-tabs) available-tabs) + toggle-tab! (fn [id checked?] + (let [next-requested-ids (if checked? + (conj selected-tab-ids id) + (filterv #(not= id %) selected-tab-ids)) + next-tab-ids (mobile-tabs/selected-tab-ids next-requested-ids + features + max-tabs)] + (set-custom-tab-ids! next-tab-ids) + (persist-mobile-tabs! next-tab-ids))) + reorder-tab! (fn [tab-ids] + (let [next-tab-ids (mobile-tabs/selected-tab-ids tab-ids features max-tabs)] + (set-custom-tab-ids! next-tab-ids) + (persist-mobile-tabs! next-tab-ids))) + selected-tabs (keep available-tabs-by-id selected-tab-ids) + unselected-tabs (remove #(contains? selected-tab-id-set (:id %)) available-tabs)] + [:div.p-4.space-y-3.min-w-64 + [:div.text-lg.font-medium (t :mobile.settings/tabs)] + [:div.space-y-2 + (dnd/items + (mapv + (fn [{:keys [id title-key]}] + (let [required? (= id mobile-tabs/required-tab-id)] + {:id id + :value id + :disabled? required? + :content (mobile-tab-picker-row + {:id id + :title-key title-key + :checked? true + :disabled? required? + :sortable? (not required?) + :toggle-tab! toggle-tab!})})) + selected-tabs) + {:on-drag-end (fn [tab-ids _drag] + (reorder-tab! tab-ids))}) + (for [{:keys [id title-key]} unselected-tabs + :let [disabled? (>= (count selected-tab-ids) max-tabs)]] + (mobile-tab-picker-row + {:id id + :key id + :title-key title-key + :checked? false + :disabled? disabled? + :sortable? false + :toggle-tab! toggle-tab!}))]])) + (rum/defc page < rum/reactive [] (let [login? (and (state/sub :auth/id-token) @@ -133,6 +226,12 @@ [:span.text-base (t :mobile.settings/version)] [:span.text-sm version/version]] + [:div.mobile-setting-item + {:on-click (fn [] + (shui/popup-show! nil (fn [] (mobile-tabs-picker)) {}))} + [:span.text-base (t :mobile.settings/tabs)] + [:span.text-sm.opacity-70 (selected-mobile-tabs-label)]] + (let [revision (string/replace (build-version/revision) "-dirty" "")] [:div.mobile-setting-item {:on-click (fn [] diff --git a/src/main/mobile/state.cljs b/src/main/mobile/state.cljs index 55aae48a96..8ba01044ac 100644 --- a/src/main/mobile/state.cljs +++ b/src/main/mobile/state.cljs @@ -7,6 +7,7 @@ (defonce *search-input (atom "")) (defonce *tab (atom "home")) + (defn set-tab! [tab] (let [prev @*tab] ;; When leaving the search tab, clear its stack so reopening starts fresh. @@ -26,6 +27,16 @@ (when data (state/pub-event! [:mobile/clear-edit]))) +(defonce *flashcards-header (atom nil)) +(defn set-flashcards-header! + [data] + (reset! *flashcards-header data)) + +(defonce *flashcards-selector (atom nil)) +(defn set-flashcards-selector! + [data] + (reset! *flashcards-selector data)) + (defonce *log (atom [])) (defn log-append! [record] diff --git a/src/main/mobile/tabs.cljs b/src/main/mobile/tabs.cljs new file mode 100644 index 0000000000..6fce923c8c --- /dev/null +++ b/src/main/mobile/tabs.cljs @@ -0,0 +1,82 @@ +(ns mobile.tabs + "Mobile bottom tab definitions and selection rules.") + +(def ^:private tab-definitions + [{:id "home" + :title-key :nav/home + :systemImage "house" + :role "normal"} + {:id "graphs" + :title-key :mobile.tab/graphs + :systemImage "app.background.dotted" + :role "normal"} + {:id "capture" + :title-key :mobile.tab/capture + :systemImage "tray" + :role "normal"} + {:id "flashcards" + :title-key :nav/flashcards + :systemImage "infinity" + :role "normal"} + {:id "go to" + :title-key :mobile.tab/go-to + :systemImage "square.stack.3d.down.right" + :role "normal"}]) + +(def default-tab-ids + (mapv :id tab-definitions)) + +(def required-tab-id "home") + +(defn max-main-tabs + [native-iphone?] + (if native-iphone? 4 5)) + +(defn available-tabs + [{:keys [flashcards?]}] + (cond->> tab-definitions + (not flashcards?) (remove #(= "flashcards" (:id %))) + true vec)) + +(defn selected-tab-ids + [custom-tab-ids features max-tabs] + (let [available-ids (set (map :id (available-tabs features))) + requested-ids (if (seq custom-tab-ids) custom-tab-ids default-tab-ids) + valid-ids (->> requested-ids + distinct + (filter available-ids)) + required-ids (cons required-tab-id + (remove #(= required-tab-id %) valid-ids))] + (->> required-ids + (take max-tabs) + vec))) + +(defn reorder-tab-ids + [tab-ids dragged-id target-id features max-tabs] + (let [selected-ids (selected-tab-ids tab-ids features max-tabs) + selected-id-set (set selected-ids) + dragged-index (.indexOf selected-ids dragged-id) + target-index (.indexOf selected-ids target-id)] + (if (or (= dragged-id required-tab-id) + (= target-id required-tab-id) + (= dragged-id target-id) + (not (contains? selected-id-set dragged-id)) + (not (contains? selected-id-set target-id))) + selected-ids + (let [without-dragged (filterv #(not= dragged-id %) selected-ids)] + (->> without-dragged + (mapcat + (fn [id] + (if (= id target-id) + (if (> target-index dragged-index) + [id dragged-id] + [dragged-id id]) + [id]))) + vec))))) + +(defn tab-configs + [custom-tab-ids features max-tabs] + (let [tabs-by-id (zipmap (map :id tab-definitions) tab-definitions)] + (->> (selected-tab-ids custom-tab-ids features max-tabs) + (keep tabs-by-id) + vec))) diff --git a/src/resources/dicts/en.edn b/src/resources/dicts/en.edn index 29f8c5d76d..ad148eb8d6 100644 --- a/src/resources/dicts/en.edn +++ b/src/resources/dicts/en.edn @@ -992,6 +992,7 @@ :mobile.settings/github "GitHub" :mobile.settings/report-bug "Report bug" :mobile.settings/revision "Revision" + :mobile.settings/tabs "Tabs" :mobile.settings/theme "Theme" :mobile.settings/version "Version" diff --git a/src/resources/dicts/zh-cn.edn b/src/resources/dicts/zh-cn.edn index 8a19631f5d..921b9702d2 100644 --- a/src/resources/dicts/zh-cn.edn +++ b/src/resources/dicts/zh-cn.edn @@ -988,6 +988,7 @@ :mobile.settings/github "GitHub 仓库" :mobile.settings/report-bug "报告问题" :mobile.settings/revision "修订" + :mobile.settings/tabs "标签页" :mobile.settings/theme "主题" :mobile.settings/version "版本" diff --git a/src/test/mobile/navigation_test.cljs b/src/test/mobile/navigation_test.cljs index 612dbf4053..2d94020bff 100644 --- a/src/test/mobile/navigation_test.cljs +++ b/src/test/mobile/navigation_test.cljs @@ -2,6 +2,7 @@ (:require [cljs.test :refer [deftest is testing use-fixtures]] [frontend.handler.route :as route-handler] [frontend.mobile.util :as mobile-util] + [frontend.state :as state] [mobile.navigation :as mobile-nav] [mobile.state :as mobile-state])) @@ -118,3 +119,14 @@ (mobile-state/set-tab! "home") (is (= "Hello" @mobile-state/*search-input)) (is (= [[:home {} {}]] @replace-calls)))))) + +(deftest selecting-flashcards-does-not-open-cards-dialog + (testing "flashcards is a normal tab surface, not a modal shortcut" + (let [events (atom []) + stacks (atom [])] + (with-redefs [state/pub-event! (fn [event] (swap! events conj event)) + mobile-nav/switch-stack! (fn [stack] (swap! stacks conj stack))] + (mobile-state/set-tab! "flashcards") + (is (= "flashcards" @mobile-state/*tab)) + (is (= ["flashcards"] @stacks)) + (is (empty? @events)))))) diff --git a/src/test/mobile/tabs_test.cljs b/src/test/mobile/tabs_test.cljs new file mode 100644 index 0000000000..2e4d58fa22 --- /dev/null +++ b/src/test/mobile/tabs_test.cljs @@ -0,0 +1,75 @@ +(ns mobile.tabs-test + (:require [cljs.test :refer [deftest is testing]] + [mobile.tabs :as tabs])) + +(deftest iphone-limits-main-tabs-to-four + (testing "iPhone keeps four configurable content tabs; native search is separate" + (is (= ["home" "graphs" "capture" "flashcards"] + (tabs/selected-tab-ids nil {:flashcards? true} (tabs/max-main-tabs true))))) + + (testing "custom tab selections are respected before applying the iPhone cap" + (is (= ["home" "graphs" "go to" "capture"] + (tabs/selected-tab-ids ["home" "graphs" "go to" "capture" "flashcards"] + {:flashcards? true} + (tabs/max-main-tabs true)))))) + +(deftest selected-tabs-ignore-unavailable-tabs + (testing "disabled flashcards and unknown tab ids are removed" + (is (= ["home" "graphs" "go to"] + (tabs/selected-tab-ids ["unknown" "home" "flashcards" "graphs" "go to"] + {:flashcards? false} + (tabs/max-main-tabs false)))))) + +(deftest selected-tabs-always-include-home + (testing "home is kept as the stable first tab even when custom data omits it" + (is (= ["home" "graphs" "capture"] + (tabs/selected-tab-ids ["graphs" "capture"] + {:flashcards? true} + (tabs/max-main-tabs false))))) + + (testing "home is moved back to the first position when custom data reorders it" + (is (= ["home" "graphs" "capture"] + (tabs/selected-tab-ids ["graphs" "home" "capture"] + {:flashcards? true} + (tabs/max-main-tabs false)))))) + +(deftest reorder-selected-tabs + (testing "dragging a selected tab before another selected tab changes tab order" + (is (= ["home" "flashcards" "graphs" "capture"] + (tabs/reorder-tab-ids ["home" "graphs" "capture" "flashcards"] + "flashcards" + "graphs" + {:flashcards? true} + (tabs/max-main-tabs true))))) + + (testing "dragging a selected tab down inserts it after the target tab" + (is (= ["home" "capture" "flashcards" "graphs"] + (tabs/reorder-tab-ids ["home" "graphs" "capture" "flashcards"] + "graphs" + "flashcards" + {:flashcards? true} + (tabs/max-main-tabs true))))) + + (testing "dragging home is ignored because it is the stable first tab" + (is (= ["home" "graphs" "capture" "flashcards"] + (tabs/reorder-tab-ids ["home" "graphs" "capture" "flashcards"] + "home" + "capture" + {:flashcards? true} + (tabs/max-main-tabs true))))) + + (testing "dragging before home is ignored because home is pinned" + (is (= ["home" "graphs" "capture" "flashcards"] + (tabs/reorder-tab-ids ["home" "graphs" "capture" "flashcards"] + "capture" + "home" + {:flashcards? true} + (tabs/max-main-tabs true))))) + + (testing "reordering ignores unavailable targets" + (is (= ["home" "graphs" "capture"] + (tabs/reorder-tab-ids ["home" "graphs" "capture"] + "capture" + "flashcards" + {:flashcards? false} + (tabs/max-main-tabs false))))))