Browse Source

enhance(mobile): native alert

Tienson Qin 1 week ago
parent
commit
9c01d46bc3

+ 116 - 1
ios/App/App/UILocalPlugin.swift

@@ -9,6 +9,7 @@ import Capacitor
 import Foundation
 import Speech
 import NaturalLanguage
+import Drops
 
 func isDarkMode() -> Bool {
     if #available(iOS 12.0, *) {
@@ -211,9 +212,123 @@ public class UILocalPlugin: CAPPlugin, CAPBridgedPlugin {
     public let pluginMethods: [CAPPluginMethod] = [
       CAPPluginMethod(name: "showDatePicker", returnType: CAPPluginReturnPromise),
       CAPPluginMethod(name: "transcribeAudio2Text", returnType: CAPPluginReturnPromise),
-      CAPPluginMethod(name: "routeDidChange", returnType: CAPPluginReturnPromise)
+      CAPPluginMethod(name: "routeDidChange", returnType: CAPPluginReturnPromise),
+      CAPPluginMethod(name: "alert", returnType: CAPPluginReturnPromise),
+      CAPPluginMethod(name: "hideAlert", returnType: CAPPluginReturnPromise)
     ]
 
+    @objc func alert(_ call: CAPPluginCall) {
+        guard let title = call.getString("title") ?? call.getString("message") else {
+            call.reject("title is required")
+            return
+        }
+
+        let subtitle = call.getString("subtitle") ?? call.getString("description")
+        let type = call.getString("type")?.lowercased()
+        let iconName = call.getString("icon")
+        let iconColorHex = call.getString("iconColor") ?? call.getString("tintColor")
+        let position = (call.getString("position")?.lowercased() == "bottom") ? Drop.Position.bottom : Drop.Position.top
+        let durationSeconds = call.getDouble("duration")
+        let accessibilityMessage = call.getString("accessibility")
+
+        let drop = Drop(
+          title: title,
+          subtitle: subtitle,
+          icon: buildIcon(type: type, iconName: iconName, hexColor: iconColorHex),
+          action: nil,
+          position: position,
+          duration: durationSeconds.flatMap { Drop.Duration.seconds($0) } ?? .recommended,
+          accessibility: accessibilityMessage.map { Drop.Accessibility(message: $0) }
+        )
+
+        Drops.show(drop)
+        call.resolve()
+    }
+
+    @objc func hideAlert(_ call: CAPPluginCall) {
+        Drops.hideAll()
+        call.resolve()
+    }
+
+    private func buildIcon(type: String?, iconName: String?, hexColor: String?) -> UIImage? {
+        let tint = color(fromHex: hexColor) ?? color(for: type)
+
+        if let iconName, let image = UIImage(systemName: iconName) {
+            guard let tint else { return image }
+            return image.withTintColor(tint, renderingMode: .alwaysOriginal)
+        }
+
+        guard let type else { return nil }
+
+        let symbolName: String
+        switch type {
+        case "success":
+            symbolName = "checkmark.circle.fill"
+        case "warning":
+            symbolName = "exclamationmark.triangle.fill"
+        case "error", "danger":
+            symbolName = "xmark.octagon.fill"
+        default:
+            symbolName = "info.circle.fill"
+        }
+
+        if let tint {
+            return UIImage(systemName: symbolName)?
+              .withTintColor(tint, renderingMode: .alwaysOriginal)
+        }
+
+        return UIImage(systemName: symbolName)
+    }
+
+    private func color(for type: String?) -> UIColor? {
+        guard let type else { return nil }
+        switch type {
+        case "success":
+            return .systemGreen
+        case "warning":
+            return .systemOrange
+        case "error", "danger":
+            return .systemRed
+        default:
+            return .systemBlue
+        }
+    }
+
+    private func color(fromHex hexString: String?) -> UIColor? {
+        guard var hex = hexString?
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+            .uppercased(),
+              !hex.isEmpty else {
+            return nil
+        }
+
+        if hex.hasPrefix("#") {
+            hex.removeFirst()
+        }
+
+        var rgbValue: UInt64 = 0
+        guard Scanner(string: hex).scanHexInt64(&rgbValue) else { return nil }
+
+        switch hex.count {
+        case 6:
+            return UIColor(
+              red: CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0,
+              green: CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0,
+              blue: CGFloat(rgbValue & 0x0000FF) / 255.0,
+              alpha: 1.0
+            )
+        case 8:
+            return UIColor(
+              red: CGFloat((rgbValue & 0xFF000000) >> 24) / 255.0,
+              green: CGFloat((rgbValue & 0x00FF0000) >> 16) / 255.0,
+              blue: CGFloat((rgbValue & 0x0000FF00) >> 8) / 255.0,
+              alpha: CGFloat(rgbValue & 0x000000FF) / 255.0
+            )
+        default:
+            return nil
+        }
+    }
+
 @available(iOS 26.0, *)
 func recognizeWithAutoLocale(from file: URL,
                              completion: @escaping (String?, Error?) -> Void) {

+ 1 - 0
ios/App/Podfile

@@ -28,6 +28,7 @@ def capacitor_pods
   pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
   pod 'CapgoCapacitorNavigationBar', :path => '../../node_modules/@capgo/capacitor-navigation-bar'
   pod 'SendIntent', :path => '../../node_modules/send-intent'
+  pod 'Drops', :git => 'https://github.com/omaralbeik/Drops.git', :tag => '1.7.0'
   pod 'JcesarmobileSslSkip', :path => '../../node_modules/@jcesarmobile/ssl-skip'
 end
 

+ 14 - 3
ios/App/Podfile.lock

@@ -10,7 +10,7 @@ PODS:
     - Capacitor
   - CapacitorCamera (7.0.1):
     - Capacitor
-  - CapacitorClipboard (7.0.1):
+  - CapacitorClipboard (7.0.2):
     - Capacitor
   - CapacitorCommunitySafeArea (7.0.0-alpha.1):
     - Capacitor
@@ -35,6 +35,7 @@ PODS:
     - Capacitor
   - CapgoCapacitorNavigationBar (7.1.32):
     - Capacitor
+  - Drops (1.7.0)
   - JcesarmobileSslSkip (0.4.0):
     - Capacitor
   - KeychainSwift (21.0.0)
@@ -60,6 +61,7 @@ DEPENDENCIES:
   - "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)"
   - "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
   - "CapgoCapacitorNavigationBar (from `../../node_modules/@capgo/capacitor-navigation-bar`)"
+  - Drops (from `https://github.com/omaralbeik/Drops.git`, tag `1.7.0`)
   - "JcesarmobileSslSkip (from `../../node_modules/@jcesarmobile/ssl-skip`)"
   - SendIntent (from `../../node_modules/send-intent`)
 
@@ -104,18 +106,26 @@ EXTERNAL SOURCES:
     :path: "../../node_modules/@capacitor/status-bar"
   CapgoCapacitorNavigationBar:
     :path: "../../node_modules/@capgo/capacitor-navigation-bar"
+  Drops:
+    :git: https://github.com/omaralbeik/Drops.git
+    :tag: 1.7.0
   JcesarmobileSslSkip:
     :path: "../../node_modules/@jcesarmobile/ssl-skip"
   SendIntent:
     :path: "../../node_modules/send-intent"
 
+CHECKOUT OPTIONS:
+  Drops:
+    :git: https://github.com/omaralbeik/Drops.git
+    :tag: 1.7.0
+
 SPEC CHECKSUMS:
   AparajitaCapacitorSecureStorage: 502bff73187cf9d0164459458ccf47ec65d5895a
   Capacitor: 03bc7cbdde6a629a8b910a9d7d78c3cc7ed09ea7
   CapacitorActionSheet: 4213427449132ae4135674d93010cb011725647e
   CapacitorApp: febecbb9582cb353aed037e18ec765141f880fe9
   CapacitorCamera: 6e73f1fc6c629a672658705a02409b60854bc0f1
-  CapacitorClipboard: 70bfdb42b877b320a6e511ab94fa7a6a55d57ecb
+  CapacitorClipboard: 7e227702976d4435a5a40df54f65e154d0dfc1f3
   CapacitorCommunitySafeArea: 3f049619072ab5d0da2529bcb05b358ff6c13dc1
   CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f
   CapacitorDevice: 81ae78d5d1942707caad79276badd458bf6ec603
@@ -128,10 +138,11 @@ SPEC CHECKSUMS:
   CapacitorSplashScreen: 1d67815a422a9b61539c94f283c08ed56667c0fc
   CapacitorStatusBar: 6e7af040d8fc4dd655999819625cae9c2d74c36f
   CapgoCapacitorNavigationBar: 067b1c1d1ede5ce96200a730ce7fd498e9641509
+  Drops: 5155b9ede54a2666b2129ac33f0734509b9f0784
   JcesarmobileSslSkip: 5fa98636a64c36faa50f32ab4daf34e38f4d45b9
   KeychainSwift: 4a71a45c802fd9e73906457c2dcbdbdc06c9419d
   SendIntent: 8a6f646a4489f788d253ffbd1082a98ea388d870
 
-PODFILE CHECKSUM: 2ba428e46e22e2bca616c46fc132c5699b98e54b
+PODFILE CHECKSUM: 4c45370d0465170bcb1b7c0b1ed45c9e46b9b742
 
 COCOAPODS: 1.16.2

+ 9 - 9
src/main/frontend/components/settings.cljs

@@ -253,14 +253,14 @@
                  tt (string/capitalize t)
                  active? (= (or type "default") t)]]
        (shui/button
-         {:variant :secondary
-          :class (when active? " border-primary border-[2px]")
-          :style {:width "4.4rem"}
-          :on-click #(state/set-editor-font! {:type t})}
-         [:span.flex.flex-col
-          {:class (str "ls-font-" t)}
-          [:strong "Ag"]
-          [:small tt]]))]
+        {:variant :secondary
+         :class (when active? " border-primary border-[2px]")
+         :style {:width "4.4rem"}
+         :on-click #(state/set-editor-font! {:type t})}
+        [:span.flex.flex-col
+         {:class (str "ls-font-" t)}
+         [:strong "Ag"]
+         [:small tt]]))]
     [:div.pt-3
      [:label.w-full.flex.items-center.cursor-pointer
       (shui/checkbox {:checked (boolean global)
@@ -1270,7 +1270,7 @@
   []
   (let [user-uuid (user-handler/user-uuid)
         token (state/get-auth-id-token)
-        refresh-token (state/get-auth-refresh-token)
+        refresh-token (str (state/get-auth-refresh-token))
         [rsa-key-pair set-rsa-key-pair!] (hooks/use-state :not-inited)
         [init-key-err set-init-key-err!] (hooks/use-state nil)
         [get-key-err set-get-key-err!] (hooks/use-state nil)

+ 2 - 2
src/main/frontend/handler/editor.cljs

@@ -1071,8 +1071,8 @@
                                       (assoc :db/id (:db/id b)))))))]
         (common-handler/copy-to-clipboard-without-id-property! repo (get block :block/format :markdown) content (when html? html) copied-blocks))
       (state/set-block-op-type! :copy)
-      (when-not (util/capacitor?)
-        (notification/show! "Copied!" :success)))))
+      ;; (notification/show! "Copied!" :success)
+      )))
 
 (defn copy-block-refs
   []

+ 1 - 1
src/main/frontend/handler/events/rtc.cljs

@@ -10,7 +10,7 @@
 
 (defmethod events/handle :rtc/decrypt-user-e2ee-private-key [[_ encrypted-private-key]]
   (let [private-key-promise (p/deferred)
-        refresh-token (state/get-auth-refresh-token)]
+        refresh-token (str (state/get-auth-refresh-token))]
     (shui/dialog-close-all!)
     (->
      (p/let [{:keys [password]} (state/<invoke-db-worker :thread-api/get-e2ee-password refresh-token)

+ 1 - 0
src/main/frontend/handler/notification.cljs

@@ -15,6 +15,7 @@
   (state/set-state! :notification/contents nil))
 
 (defn show!
+  "status: :info/:warning/:error/:success"
   ([content]
    (show! content :info true nil 2000 nil))
   ([content status]

+ 3 - 1
src/main/frontend/handler/user.cljs

@@ -87,7 +87,9 @@
    :sub))
 
 (defn logged-in? []
-  (some? (state/get-auth-refresh-token)))
+  (let [token (state/get-auth-refresh-token)]
+    (when (string? token)
+      (not (string/blank? token)))))
 
 (defn- set-token-to-localstorage!
   ([id-token access-token]

+ 32 - 0
src/main/frontend/mobile/util.cljs

@@ -101,6 +101,38 @@
   [path]
   (string/includes? path "/iCloud~com~logseq~logseq/"))
 
+(defn alert
+  "Show a native drop alert on iOS.
+   Options: :title or :message (required), :subtitle, :type (info/success/warning/error),
+   :icon (SF Symbols name), :icon-color (hex string), :tint-color (alias for icon tint),
+   :position (:top/:bottom), :duration (seconds), :accessibility (VoiceOver text)."
+  [{:keys [title message subtitle type icon icon-color tint-color position duration accessibility]}]
+  (let [title (or title message)
+        type-str (cond
+                   (keyword? type) (name type)
+                   (string? type) type)
+        position-str (cond
+                       (keyword? position) (name position)
+                       (string? position) position)
+        payload (cond-> {:title title}
+                  subtitle (assoc :subtitle subtitle)
+                  type-str (assoc :type type-str)
+                  icon (assoc :icon icon)
+                  icon-color (assoc :iconColor icon-color)
+                  tint-color (assoc :tintColor tint-color)
+                  position-str (assoc :position position-str)
+                  duration (assoc :duration duration)
+                  accessibility (assoc :accessibility accessibility))]
+    (cond
+      (not title) (p/rejected (js/Error. "title is required"))
+      (native-ios?) (.alert ^js ui-local (clj->js payload))
+      :else (p/resolved nil))))
+
+(defn hide-alert []
+  (if (native-ios?)
+    (.hideAlert ^js ui-local)
+    (p/resolved nil)))
+
 (comment
   (defn app-active?
     "Whether the app is active. This function returns a promise."

+ 1 - 1
src/main/frontend/state.cljs

@@ -2149,7 +2149,7 @@ Similar to re-frame subscriptions"
   (sub :auth/id-token))
 
 (defn get-auth-refresh-token []
-  (str (:auth/refresh-token @state)))
+  (:auth/refresh-token @state))
 
 (defn set-file-sync-manager [graph-uuid v]
   (when (and graph-uuid v)

+ 3 - 1
src/main/mobile/components/header.cljs

@@ -105,7 +105,9 @@
    (fn []
      [:div
       (when (user-handler/logged-in?)
-        (ui/menu-link {:on-click #(user-handler/logout)}
+        (ui/menu-link {:on-click #(p/do!
+                                   (user-handler/logout)
+                                   (shui/popup-hide!))}
                       [:span.text-lg.flex.gap-2.items-center.text-red-700
                        (shui/tabler-icon "logout" {:class "opacity-80" :size 22})
                        "Logout"]))

+ 36 - 0
src/main/mobile/core.cljs

@@ -5,7 +5,9 @@
             [frontend.background-tasks]
             [frontend.handler :as fhandler]
             [frontend.handler.db-based.rtc-background-tasks]
+            [frontend.handler.notification :as notification]
             [frontend.handler.route :as route-handler]
+            [frontend.mobile.util :as mobile-util]
             [frontend.state :as state]
             [lambdaisland.glogi :as log]
             [mobile.components.app :as app]
@@ -18,6 +20,40 @@
             [reitit.frontend :as rf]
             [reitit.frontend.easy :as rfe]))
 
+(defn- alert*
+  [content status timeout]
+  (if (string? content)
+    (mobile-util/alert {:title content
+                        :type (or (when (keyword? status) (name status)) "info")
+                        :duration timeout
+                        :position "top"})
+    (log/warn ::native-alert-non-string {:content content})))
+
+(defn- alert
+  "Native mobile alert replacement for `frontend.handler.notification/show!`."
+  ([content]
+   (alert content :info nil nil nil nil))
+  ([content status]
+   (alert content status nil nil nil nil))
+  ([content status clear?]
+   (alert content status clear? nil nil nil))
+  ([content status clear? uid]
+   (alert content status clear? uid nil nil))
+  ([content status clear? uid timeout]
+   (alert content status clear? uid timeout nil))
+  ([content status _clear? _uid timeout _close-cb]
+   (alert* content status timeout)))
+
+(set! notification/show! alert)
+
+(set! notification/clear!
+      (fn [_]
+        (mobile-util/hide-alert)))
+
+(set! notification/clear-all!
+      (fn [_]
+        (mobile-util/hide-alert)))
+
 (defonce ^js root (rdc/createRoot (.getElementById js/document "root")))
 
 (defn ^:export render!