enhance(mobile): improve flashcards tab

This commit is contained in:
Tienson Qin
2026-05-19 08:32:10 +08:00
parent 519bdf39e0
commit 7efb9aa0a7
15 changed files with 568 additions and 117 deletions

View File

@@ -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)

View File

@@ -115,6 +115,22 @@
q)]
(db-async/<q repo {:transact-db? false} q' card-ids now-inst-ms (:rules result))))
(defn- global-cards-id?
[cards-id]
(contains? #{:global "global"} cards-id))
(defn- selected-cards-title
[all-cards cards-id]
(or (some (fn [card]
(when (= (:db/id card) cards-id)
(:block/title card)))
all-cards)
(some (fn [card]
(when (= (:db/id card) :global)
(:block/title card)))
all-cards)
(t :flashcard/all-cards)))
(defn- <create-cards-block!
[]
(editor-handler/api-insert-new-block! ""
@@ -123,7 +139,8 @@
:sibling? false
:end? true}))
(defn- btn-with-shortcut [{:keys [shortcut id btn-text due on-click class]}]
(defn- btn-with-shortcut [{:keys [shortcut id btn-text due on-click class show-due? mobile?]
:or {show-due? true}}]
(let [bg-class (case id
"card-again" "primary-red"
"card-hard" "primary-purple"
@@ -131,21 +148,29 @@
"card-easy" "primary-green"
nil)]
[:div.flex.flex-row.items-center.gap-2
{:class (when mobile? "w-full")}
(shui/button
{:variant :outline
:title (t :flashcard/shortcut-tooltip shortcut)
:auto-focus false
:size :sm
:id id
:class (str id " " class " !px-2 !py-1 bg-primary/5 hover:bg-primary/10
(cond->
{: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/<get-block repo block-id {:children? true})]
(reset! *block result))
(assoc state ::block *block)))}
[state repo _block-id *card-index *phase]
[state repo _block-id *card-index *phase opts]
(when-let [block (rum/react (::block state))]
(when-let [block-entity (db/sub-block (:db/id block))]
(let [phase (rum/react *phase)
_card-index (rum/react *card-index)
next-phase (phase->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 (<get-due-card-block-ids repo cards-id')]
(reset! *card-index 0)
(reset! *block-ids result))))]
(when (false? loading?)
(when mobile?
(when-let [on-header-change (:on-header-change opts)]
(on-header-change {:title (selected-cards-title all-cards cards-id)
:progress progress-label}))
(when-let [on-selector-change (:on-selector-change opts)]
(on-selector-change {:cards all-cards
:cards-id cards-id
:select-card! select-card!})))
[:div#cards-modal.flex.flex-col.gap-8.flex-1
[:div.flex.flex-row.items-center.gap-2.flex-wrap
(shui/select
{:on-value-change (fn [v]
(reset! *cards-id v)
(let [cards-id' (when-not (contains? #{:global "global"} v) v)]
(p/let [result (<get-due-card-block-ids repo cards-id')]
(reset! *card-index 0)
(reset! *block-ids result))))
:default-value cards-id}
(shui/select-trigger
{:class "!px-2 !py-0 !h-8 w-64"}
(shui/select-value
{:placeholder (t :flashcard/select-cards)}))
(shui/select-content
(shui/select-group
(for [card-entity all-cards]
(shui/select-item {:value (:db/id card-entity)}
(:block/title card-entity))))))
(shui/button
{:variant :ghost
:id "ls-cards-add"
:size :sm
:title (t :flashcard/add-query)
:class "!px-1 text-muted-foreground"
:on-click (fn []
(p/let [saved-block (<create-cards-block!)]
(shui/dialog-close!)
(when saved-block
(route-handler/redirect-to-page! (:block/uuid saved-block)
{}))))}
(ui/icon "plus"))
[:span.text-sm.opacity-50 (str (min (inc @*card-index) (count @*block-ids)) "/" (count @*block-ids))]]
(let [block-id (nth block-ids @*card-index nil)]
(when-not mobile?
[:div.flex.flex-row.items-center.gap-2
(shui/select
{:on-value-change select-card!
:default-value cards-id}
(shui/select-trigger
{:class "!px-2 !py-0 !h-8 w-64"}
(shui/select-value
{:placeholder (t :flashcard/select-cards)}))
(shui/select-content
(shui/select-group
(for [card-entity all-cards]
(shui/select-item {:value (:db/id card-entity)}
(:block/title card-entity))))))
(shui/button
{:variant :ghost
:id "ls-cards-add"
:size :sm
:title (t :flashcard/add-query)
:class "!px-1 text-muted-foreground"
:on-click (fn []
(p/let [saved-block (<create-cards-block!)]
(shui/dialog-close!)
(when saved-block
(route-handler/redirect-to-page! (:block/uuid saved-block)
{}))))}
(ui/icon "plus"))
[:span.text-sm.opacity-50.whitespace-nowrap progress-label]])
(let [block-id (nth block-ids card-index nil)]
(cond
block-id
[:div.flex.flex-col
(rum/with-key
(card-view repo block-id *card-index *phase)
(card-view repo block-id *card-index *phase opts)
(str "card-" block-id))]
(empty? block-ids)

View File

@@ -118,7 +118,7 @@
(defmethod events/handle :modal/show-cards [[_ cards-id]]
(shui/dialog-open!
(fn [] (fsrs/cards-view cards-id))
(fn [] (fsrs/cards-view cards-id nil))
{:id :srs
:label :flashcards__cp}))

View File

@@ -30,6 +30,7 @@
(s/def :copy/export-block-text-other-options map?)
(s/def ::sync-server-url string?)
(s/def ::publish-server-url string?)
(s/def ::ls-mobile-tabs (s/coll-of string? :kind vector?))
;; Dynamic keys which aren't as easily validated:
;; :ls-pdf-last-page-*
;; :ls-js-allowed-*
@@ -70,4 +71,5 @@
:copy/export-block-text-remove-options
:copy/export-block-text-other-options
::sync-server-url
::publish-server-url]))
::publish-server-url
::ls-mobile-tabs]))

View File

@@ -10,11 +10,13 @@
[frontend.handler.route :as route-handler]
[frontend.mobile.util :as mobile-util]
[frontend.state :as state]
[frontend.storage :as storage]
[frontend.util :as util]
[lambdaisland.glogi :as log]
[mobile.navigation :as mobile-nav]
[mobile.search :as mobile-search]
[mobile.state :as mobile-state]
[mobile.tabs :as mobile-tabs]
[promesa.core :as p]))
;; Capacitor plugin instance (nil if native side hasn't shipped it yet).
@@ -260,28 +262,30 @@
(add-graph-action-listener!)
(add-keyboard-hack-listener!)))
(defn- translated-tab
[tab]
(-> 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"})))))

View File

@@ -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

View File

@@ -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;

View File

@@ -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)))

View File

@@ -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 []

View File

@@ -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]

82
src/main/mobile/tabs.cljs Normal file
View File

@@ -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)))

View File

@@ -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"

View File

@@ -988,6 +988,7 @@
:mobile.settings/github "GitHub 仓库"
:mobile.settings/report-bug "报告问题"
:mobile.settings/revision "修订"
:mobile.settings/tabs "标签页"
:mobile.settings/theme "主题"
:mobile.settings/version "版本"

View File

@@ -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))))))

View File

@@ -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))))))