From 014f9f0757ec442c2121b577b0c81559cb3e58ef Mon Sep 17 00:00:00 2001 From: ADSFAaron Date: Thu, 3 Sep 2026 19:39:12 +0800 Subject: [PATCH 1/4] chore(build): AGP 9.3.2 Co-Authored-By: Claude Opus 5 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 082f161..fa79b2e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.3.1" +agp = "9.3.2" kotlin = "2.4.10" # KSP2 has its own version line and does NOT track the Kotlin version — 2.3.11 is what # supports Kotlin 2.4.x. Check https://github.com/google/ksp/releases before bumping Kotlin. From 7c4208c668c0cf3c7df44ae70163ac06e6262c65 Mon Sep 17 00:00:00 2001 From: ADSFAaron Date: Thu, 3 Sep 2026 19:39:40 +0800 Subject: [PATCH 2/4] feat(config): say what the battery trigger does that the condition does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two get mixed up because their summaries read as the same sentence. The trigger fires once, at the crossing; the condition is checked before every run and starts nothing by itself. Both config dialogs now lead with a note saying so, and a trigger summary reads "Drops to 20% or below" where the condition's reads "While at or below 20%" — instead of both saying "20% below". The section headers and the trigger's "when" label follow the same wording in the three translations. Co-Authored-By: Claude Opus 5 --- .../ui/flows/detail/config/ConditionConfig.kt | 14 +++++++++---- .../ui/flows/detail/config/TriggerConfig.kt | 15 +++++++++---- app/src/main/res/values-ja/strings.xml | 17 ++++++++------- app/src/main/res/values-zh-rCN/strings.xml | 21 +++++++++++-------- app/src/main/res/values-zh-rTW/strings.xml | 21 +++++++++++-------- app/src/main/res/values/strings.xml | 9 +++++--- 6 files changed, 61 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/nexflow/ui/flows/detail/config/ConditionConfig.kt b/app/src/main/java/com/nexflow/ui/flows/detail/config/ConditionConfig.kt index 949a442..b122353 100644 --- a/app/src/main/java/com/nexflow/ui/flows/detail/config/ConditionConfig.kt +++ b/app/src/main/java/com/nexflow/ui/flows/detail/config/ConditionConfig.kt @@ -96,6 +96,11 @@ fun ConditionType.info(context: Context): ConditionInfo = when (this) { context.getString(R.string.cnd_battery_label), Icons.Outlined.BatteryAlert, context.getString(R.string.cnd_battery_desc), listOf( + ConfigField.InfoText( + "_battery_condition_info", + context.getString(R.string.cfg_info_note_label), + context.getString(R.string.cfg_info_battery_condition_body), + ), ConfigField.Dropdown( "direction", context.getString(R.string.cfg_battery_when), listOf( "BELOW" to context.getString(R.string.opt_at_or_below), @@ -190,10 +195,11 @@ fun ConditionType.configSummary( } ConditionType.DAY_OF_WEEK -> config["days"]?.takeIf { it.isNotBlank() } ?: context.getString(R.string.sum_any_day) - ConditionType.BATTERY_LEVEL -> { - val op = if (config["direction"]?.uppercase() == "ABOVE") "≥" else "≤" - "$op ${config["level"] ?: "20"}%" - } + ConditionType.BATTERY_LEVEL -> context.getString( + if (config["direction"]?.uppercase() == "ABOVE") R.string.sum_battery_at_or_above + else R.string.sum_battery_at_or_below, + config["level"] ?: "20", + ) ConditionType.CHARGING -> context.getString( if (config["state"]?.uppercase() == "NOT_CHARGING") R.string.opt_not_charging else R.string.opt_charging, ) diff --git a/app/src/main/java/com/nexflow/ui/flows/detail/config/TriggerConfig.kt b/app/src/main/java/com/nexflow/ui/flows/detail/config/TriggerConfig.kt index bdde310..0aa9bd6 100644 --- a/app/src/main/java/com/nexflow/ui/flows/detail/config/TriggerConfig.kt +++ b/app/src/main/java/com/nexflow/ui/flows/detail/config/TriggerConfig.kt @@ -120,6 +120,13 @@ fun TriggerType.info(context: Context): TriggerInfo = when (this) { TriggerType.BATTERY -> TriggerInfo( context.getString(R.string.trg_battery_label), Icons.Outlined.BatteryAlert, context.getString(R.string.trg_battery_desc), listOf( + // Leads the dialog because the edge/state distinction against the BATTERY_LEVEL + // condition is the thing people get wrong, and they get it wrong before they read on. + ConfigField.InfoText( + "_battery_trigger_info", + context.getString(R.string.cfg_info_note_label), + context.getString(R.string.cfg_info_battery_trigger_body), + ), ConfigField.Slider("level", context.getString(R.string.cfg_battery_level), 0, 100, "%"), ConfigField.Dropdown("direction", context.getString(R.string.cfg_trigger_when), listOf( "BELOW" to context.getString(R.string.opt_falls_below), @@ -284,10 +291,10 @@ fun TriggerType.configSummary(context: Context, config: Map): St append("$t · $label") } } - TriggerType.BATTERY -> { - val dir = context.getString(if (config["direction"] == "ABOVE") R.string.sum_battery_above else R.string.sum_battery_below) - context.getString(R.string.sum_battery, config["level"] ?: "?", dir) - } + TriggerType.BATTERY -> context.getString( + if (config["direction"] == "ABOVE") R.string.sum_battery_rises else R.string.sum_battery_falls, + config["level"] ?: "?", + ) TriggerType.BLUETOOTH -> { val d = config["device_name"]?.takeIf { it.isNotBlank() } ?: context.getString(R.string.sum_any_device) context.getString(R.string.sum_device_event, d, connectEventLabel(context, config["event"])) diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 9fef5d4..a68e4a1 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -58,9 +58,9 @@ フローをエクスポート エクスポート Gemini で編集 - トリガー - 条件 - アクション + 〜のとき + しかも〜の場合だけ + 実行する 変数 トリガーがありません。下から追加してください。 条件がありません。トリガーが作動するたびにフローが実行されます。 @@ -646,7 +646,7 @@ 繰り返し カスタム曜日 バッテリー残量 - トリガー条件 + トリガーのタイミング 充電状態 デバイス名(任意) ネットワーク名(任意) @@ -690,6 +690,8 @@ スクリーンショットには NexFlow ユーザー補助サービスの有効化が必要です。「設定 → ユーザー補助 → インストール済みアプリ → NexFlow」でオンにしてください。Android 12 以上が必要です。 ネットワーク名の照合には位置情報の権限が必要です。権限がないと Android は名前を隠すため、この条件は決して成立しません。空欄にすると、どの Wi-Fi でも成立します。 検出できるのはオーディオ機器(ヘッドホン、スピーカー、カーオーディオ)だけです。その他の Bluetooth 機器はこの条件では検出できません。 + 残量がこのしきい値を「またいだ」瞬間に一度だけ作動します。低いままでも再度は作動しません。別のタイミングで残量を見たい場合(定時実行など)は、「しかも〜の場合だけ」の「バッテリー残量」条件を使ってください。 + どのトリガーで起動したかに関わらず、実行の直前に毎回チェックされます。これ自体は何も起動しません——「バッテリー」トリガーと違い、その実行を通すか止めるかを決めるだけです。 条件 例:{{battery}} < 20 · {{status}} == ok · true。==、!=、<、>、<=、>= をサポート——数値は数値として比較されます。「もし終了」アクションでブロックを閉じてください。 @@ -760,9 +762,10 @@ 曜日なし レベル:%1$s%2$s %1$s:%2$s - %1$s%% %2$s - 未満 - 超過 + %1$s%% 以下になったとき + %1$s%% 以上になったとき + 残量が %1$s%% 以下のあいだ + 残量が %1$s%% 以上のあいだ %1$s %2$s diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5e51368..2fa6836 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -58,9 +58,9 @@ 导出流程 导出 用 Gemini 修改 - 触发 - 限制条件 - 动作 + 当…的时候 + 而且只有在 + 就执行 变量 尚无触发器,请在下方添加。 尚无限制条件。只要触发器响应,流程就会执行。 @@ -646,7 +646,7 @@ 重复 自定义日期 电池电量 - 触发条件 + 触发时机 充电状态 设备名称(可选) 网络名称(可选) @@ -690,6 +690,8 @@ 屏幕截图需要启用 NexFlow 无障碍服务。请前往“设置 → 无障碍 → 已安装的应用 → NexFlow”并开启。需要 Android 12 及以上。 匹配网络名称需要定位权限 — 没有权限时 Android 会隐藏名称,该条件将永远不成立。留空即接受任意 Wi-Fi 网络。 只能检测音频设备(耳机、音箱、车载音响),其他蓝牙配件无法被该条件检测。 + 只在电量「跨过」这个门槛的那一刻触发一次;电量一直停在门槛以下并不会再次触发。想在别的时机检查电量(例如定时执行时),请改用「而且只有在」区段里的「电池电量」限制条件。 + 不论是哪个触发器叫醒流程,每次执行前都会检查一次当下电量。它本身不会启动任何东西——和「电池」触发器不同,它只决定这次执行放不放行。 条件 示例:{{battery}} < 20 · {{status}} == ok · true。支持 ==、!=、<、>、<=、>=——数字按数值比较。请用“结束如果”动作关闭此块。 @@ -710,8 +712,8 @@ 仅周末 自定义日期… 一次 - 低于 - 高于 + 掉到…以下 + 升到…以上 任意 充电中 未充电 @@ -760,9 +762,10 @@ 无日期 等级:%1$s%2$s %1$s:%2$s - %1$s%% %2$s - 以下 - 以上 + 掉到 %1$s%% 以下时 + 升到 %1$s%% 以上时 + 电量在 %1$s%% 以下时 + 电量在 %1$s%% 以上时 %1$s %2$s diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 3aa9ef2..8ca50a2 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -58,9 +58,9 @@ 匯出流程 匯出 用 Gemini 修改 - 觸發 - 限制條件 - 動作 + 當…的時候 + 而且只有在 + 就執行 變數 尚無觸發器,請在下方新增。 尚無限制條件。只要觸發器響應,流程就會執行。 @@ -646,7 +646,7 @@ 重複 自訂日期 電池電量 - 觸發條件 + 觸發時機 充電狀態 裝置名稱(選填) 網路名稱(選填) @@ -690,6 +690,8 @@ 螢幕截圖需要啟用 NexFlow 無障礙服務。請至「設定 → 無障礙 → 已安裝的應用程式 → NexFlow」並開啟。需要 Android 12 以上。 比對網路名稱需要定位權限 — 沒有權限時 Android 會隱藏名稱,這個條件將永遠不成立。留空即接受任何 Wi-Fi 網路。 只偵測得到音訊裝置(耳機、喇叭、車用音響),其他藍牙配件無法被這個條件偵測。 + 只在電量「跨過」這個門檻的那一刻觸發一次;電量一直停在門檻以下並不會再次觸發。想在別的時機檢查電量(例如定時執行時),請改用「而且只有在」區段裡的「電池電量」限制條件。 + 不論是哪個觸發器叫醒流程,每次執行前都會檢查一次當下電量。它本身不會啟動任何東西——和「電池」觸發器不同,它只決定這次執行放不放行。 條件 範例:{{battery}} < 20 · {{status}} == ok · true。支援 ==、!=、<、>、<=、>=——數字會以數值比較。請以「結束如果」動作關閉此區塊。 @@ -710,8 +712,8 @@ 僅週末 自訂日期… 一次 - 低於 - 高於 + 掉到…以下 + 升到…以上 任何 充電中 未充電 @@ -760,9 +762,10 @@ 無日期 等級:%1$s%2$s %1$s:%2$s - %1$s%% %2$s - 以下 - 以上 + 掉到 %1$s%% 以下時 + 升到 %1$s%% 以上時 + 電量在 %1$s%% 以下時 + 電量在 %1$s%% 以上時 %1$s %2$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7203942..97908d1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -705,6 +705,8 @@ Screenshot requires the NexFlow Accessibility Service to be enabled. Go to Settings → Accessibility → Installed apps → NexFlow and toggle it on. Requires Android 12 or higher. Matching a network name needs location permission — without it Android hides the name and the condition never holds. Leave it blank to accept any Wi-Fi network. Only audio devices are detected (headphones, speakers, car kits). Other Bluetooth accessories are invisible to this condition. + Fires once, at the moment the level crosses this threshold — not again while the battery stays there. To check the level at some other moment (a scheduled run, say), add the Battery level condition under ONLY IF instead. + Checked just before every run, whatever fired the flow. It starts nothing by itself — unlike the Battery trigger, it only lets a run through or holds it back. Condition Examples: {{battery}} < 20 · {{status}} == ok · true. Supports ==, !=, <, >, <=, >= — numbers compare numerically. Close the block with an End If action. @@ -777,9 +779,10 @@ no days Level: %1$s%2$s %1$s: %2$s - %1$s%% %2$s - below - above + Drops to %1$s%% or below + Rises to %1$s%% or above + While at or below %1$s%% + While at or above %1$s%% %1$s %2$s From 3daa3766914e7672716919e3313470c20d0b0a0e Mon Sep 17 00:00:00 2001 From: ADSFAaron Date: Thu, 3 Sep 2026 19:40:27 +0800 Subject: [PATCH 3/4] feat(ui): duplicate a flow, fit the widget to its size, and fix four things that only showed up on a real phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release 1.6.0 (versionCode 11). No new automation; this is the round of "something here is off" that only a light theme, a resized widget and a failing API call could produce. Duplicating a flow (detail screen, ⋮ → Duplicate flow) copies everything and saves the copy DISABLED: carrying the enabled flag over would have the copy register its own triggers the moment it is saved — the same alarm, the same geofence, running twice before the user has changed a thing. Trigger, condition and action ids are regenerated too, because a trigger id is what the engine registers real system resources under, and two flows sharing one would fight over the registration. The three top-bar icons move into that ⋮. The flow name is the only thing on that screen saying which flow is being edited, and three icons cut it down to six characters — a copy could not be told from its original. The widget now lays itself out from its real size (SizeMode.Exact): columns from how wide a card is allowed to be (92dp floor) rather than from screen-width breakpoints, whole rows only, and cards stretched to fill what is left. The old breakpoints put four columns past 430dp — unreachable on a ~400dp phone home screen — while handing out 69dp cards at 180dp. A short widget switches its cards to icon-beside-name, so a 4x1 is one row of two whole cards instead of a row cut in half. Widget state keeps 12 flows (recents first, topped up with the rest) instead of sharing the launcher's four, the header drops out below 150dp, and "2/22" is now "2/22 on". Also replaces the lazy list with a plain Column: a RemoteViews collection loads its rows asynchronously, so every resize flashed an empty widget and left a scrollbar down the side. The grid already works out what fits. Fixed: - The running-flows panel had its drop shadow cut off square. Its popup window was sized to the panel, leaving the shadow nowhere to fall. It now fills the window, which also gives the panel a scrim to sit on. - The executing action was highlighted with primary at 18% alpha over an 8dp shadow. A translucent surface does not hide its own shadow, so in light theme the shadow showed through as a grey smear under the text; dark theme hid it, which is why it lasted. Now primaryContainer/onPrimaryContainer and no shadow, which is how M3 says to render state. - Flicking a flow's switch bumped updated_at, and the list is ordered by it, so the flow shot to the top and out from under the finger. Toggling is not an edit. - Toasts queue rather than replace, so a loop that toasts three times was still popping messages six seconds after the flow had finished. Each one now cancels the one on screen. - Retrying a failed AI turn drew two bubbles on top of each other: retry deletes the error and starts the turn in the same breath, so the indicator landed in the slot the error was still fading out of. The error bubble now leaves without a fade (fadeOutSpec = null); the indicator gains the entry animation it never had. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 47 +++ app/build.gradle.kts | 4 +- .../com/nexflow/data/local/dao/FlowDao.kt | 10 +- .../data/repository/FlowRepositoryImpl.kt | 2 +- .../nexflow/executor/ToastActionExecutor.kt | 12 +- .../nexflow/shortcut/ShortcutSyncManager.kt | 16 +- .../java/com/nexflow/ui/ai/AiChatScreen.kt | 32 +- .../java/com/nexflow/ui/flows/FlowsScreen.kt | 192 ++++++++---- .../ui/flows/detail/FlowDetailScreen.kt | 75 ++++- .../ui/flows/detail/FlowDetailViewModel.kt | 34 ++ .../nexflow/ui/navigation/AppNavigation.kt | 2 + .../java/com/nexflow/widget/NexFlowWidget.kt | 292 ++++++++++++------ .../res/layout/widget_preview_overview.xml | 16 +- app/src/main/res/values-ja/strings.xml | 5 + app/src/main/res/values-zh-rCN/strings.xml | 5 + app/src/main/res/values-zh-rTW/strings.xml | 5 + app/src/main/res/values/strings.xml | 6 +- app/src/main/res/xml/nexflow_widget_info.xml | 2 + 18 files changed, 558 insertions(+), 199 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34965c6..f1501a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,52 @@ (尚無項目) +## [1.6.0] - 2026-09-03 + +這一版沒有新的自動化能力,處理的是「用起來哪裡不對勁」:一個只在淺色模式現形的繪製錯誤、 +一個把流程從指尖底下抽走的排序、以及桌面小工具永遠只認得一種尺寸。 + +### 新增(Added) + +- **複製流程**:詳細頁右上角 ⋮ → 複製流程。副本會複製全部觸發器、條件、動作與變數,但 + **一律以停用狀態建立**——照抄啟用狀態的話,副本一存檔就會註冊自己的觸發器,等於同一個鬧鐘、 + 同一個地理圍欄跑兩份,使用者還來不及改任何東西。觸發器/條件/動作的 id 也全部重新產生: + 引擎是拿 trigger id 去向系統註冊真實資源的,兩個流程共用同一個 id 會互相蓋掉對方的註冊 +- **小工具會依實際尺寸重排**:欄數由「一張卡片放得下」決定(最窄 92dp),列數與卡片高度由 + 可用高度決定,而且卡片會撐開填滿剩餘空間。矮的時候卡片改成橫式(圖示在名稱左邊), + 所以 4×1 就是一列兩張完整的卡,不再被裁掉半排 + - 小工具狀態改存最多 12 個流程(近期執行過的優先,不足的用其餘流程補滿)。原本與長按捷徑 + 共用同一份「最近 4 筆」,大尺寸小工具因此永遠有一半是空的 + - 標頭在高度不足 150dp 時整條隱藏——一列高的小工具上,「NexFlow」佔的是使用者真正想看的東西的位置 + - 「2/22」改為「2/22 已啟用」。單獨兩個數字只有寫的人看得懂 + +### 變更(Changed) + +- **詳細頁的三顆圖示收進 ⋮ 選單**(重新命名/複製流程/釘到桌面)。流程名稱是這個畫面唯一能 + 分辨「這是原本的還是副本」的東西,三顆圖示會把它壓到只剩前六個字 +- **電池觸發器與電池條件各自加上說明**,並改寫兩者的摘要文案(「降到 20% 以下」對 + 「低於或等於 20% 時」)。這兩個東西最常被搞混:觸發器是「跨越門檻的那一瞬間」發生一次, + 條件是「每次執行前檢查」、自己不會啟動任何東西 +- 撥動流程開關不再更新 `updated_at`。清單是照 `updated_at` 排序的,所以開關一撥,那個流程就 + 從指尖底下飛到第一位,看起來像是消失了。撥開關不是對流程的編輯 + +### 修正(Fixed) + +- **「執行中」浮層的陰影被切成方形硬邊**(淺色模式尤其明顯):popup 視窗原本只有內容那麼大, + Surface 的陰影畫在自己邊界之外,沒有空間可落。改成鋪滿視窗的 popup,陰影完整呈現, + 背後也順勢有了 32% 的遮罩;面板本身放大、行距加寬,執行中流程多時在面板內捲動 +- **執行中的動作在淺色模式糊成一團**:高亮原本是 `primary` 18% 透明度加 8dp 陰影, + 半透明的 Surface 擋不住自己的陰影,陰影直接透出來壓在文字下。深色模式看不出來,所以一直沒被發現。 + 改用 `primaryContainer` / `onPrimaryContainer` 這組容器角色(不透明、對比度經過校準)並移除陰影—— + Material 3 的狀態本來就該用顏色角色表達,不是 alpha 加陰影 +- **快顯訊息比流程晚結束**:Toast 是排隊不是取代,迴圈跳三次要六秒才播完,流程早就結束了訊息 + 還在冒。現在貼新的之前會先取消畫面上那則 +- **AI 對話按「重試」時兩個泡泡重疊**:重試會先移除錯誤訊息、立刻開始新回合,而「思考中」指示器 + 正好被排進錯誤泡泡還在原地淡出的那個位置。錯誤泡泡改為離場不做動畫(`fadeOutSpec = null`), + 淡入與位移動畫保留 +- AI 對話的「思考中」指示器原本沒有任何進場動畫,是硬跳出來的;現在與其他泡泡使用同一組 + 清單項目動畫 + ## [1.5.1] - 2026-08-30 把 1.5.0 為了修好「NFC 被獨占」而一併拿掉的背景感應補回來,但這次只發給真的需要的人。 @@ -271,6 +317,7 @@ AI 對話從「能用」變成「好用」:回覆逐字出現而不是等半 - 多語言:繁體中文、簡體中文、日文、英文 - 匯入/匯出 `.flow`(JSON)與 MacroDroid `.mdr` 相容解析 +[1.6.0]: https://github.com/ADSFAaron/NexFlow/releases/tag/v1.6.0 [1.5.1]: https://github.com/ADSFAaron/NexFlow/releases/tag/v1.5.1 [1.5.0]: https://github.com/ADSFAaron/NexFlow/releases/tag/v1.5.0 [1.4.0]: https://github.com/ADSFAaron/NexFlow/releases/tag/v1.4.0 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a2ed2f0..547bd1d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { applicationId = "com.adsf.nexflow" minSdk = 30 targetSdk = 37 - versionCode = 10 - versionName = "1.5.1" + versionCode = 11 + versionName = "1.6.0" testInstrumentationRunner = "com.nexflow.HiltTestRunner" } diff --git a/app/src/main/java/com/nexflow/data/local/dao/FlowDao.kt b/app/src/main/java/com/nexflow/data/local/dao/FlowDao.kt index 42a5d09..cdd7bc4 100644 --- a/app/src/main/java/com/nexflow/data/local/dao/FlowDao.kt +++ b/app/src/main/java/com/nexflow/data/local/dao/FlowDao.kt @@ -41,6 +41,12 @@ interface FlowDao { @Query("DELETE FROM flows WHERE id = :id") suspend fun deleteById(id: String) - @Query("UPDATE flows SET enabled = :enabled, updated_at = :updatedAt WHERE id = :id") - suspend fun setEnabled(id: String, enabled: Boolean, updatedAt: Long) + /** + * Deliberately leaves `updated_at` alone. The list is ordered by it, so bumping it here made + * flicking a flow's switch fire the flow up to the top of the list and out from under the + * user's finger — which reads as the flow having vanished. Flipping a switch is not an edit + * to the flow. + */ + @Query("UPDATE flows SET enabled = :enabled WHERE id = :id") + suspend fun setEnabled(id: String, enabled: Boolean) } diff --git a/app/src/main/java/com/nexflow/data/repository/FlowRepositoryImpl.kt b/app/src/main/java/com/nexflow/data/repository/FlowRepositoryImpl.kt index c2473f5..3f76168 100644 --- a/app/src/main/java/com/nexflow/data/repository/FlowRepositoryImpl.kt +++ b/app/src/main/java/com/nexflow/data/repository/FlowRepositoryImpl.kt @@ -74,7 +74,7 @@ class FlowRepositoryImpl @Inject constructor( } override suspend fun setEnabled(id: String, enabled: Boolean) { - flowDao.setEnabled(id, enabled, System.currentTimeMillis()) + flowDao.setEnabled(id, enabled) } override suspend fun saveExecutionLog(log: ExecutionLog, steps: List) { diff --git a/app/src/main/java/com/nexflow/executor/ToastActionExecutor.kt b/app/src/main/java/com/nexflow/executor/ToastActionExecutor.kt index 0c7eb1e..4a974ae 100644 --- a/app/src/main/java/com/nexflow/executor/ToastActionExecutor.kt +++ b/app/src/main/java/com/nexflow/executor/ToastActionExecutor.kt @@ -36,8 +36,18 @@ class ToastActionExecutor @Inject constructor( val message = action.config["message"]?.takeIf { it.isNotBlank() } ?: return ActionResult.Skipped withContext(Dispatchers.Main) { - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + // Toasts queue rather than replace: a loop that toasts three times plays out over + // six seconds, still popping messages long after the flow itself has finished, which + // reads as the run being stuck. Cancelling the one on screen before posting the next + // keeps what the user sees in step with where the flow actually is. + onScreen?.cancel() + onScreen = Toast.makeText(context, message, Toast.LENGTH_SHORT).also { it.show() } } return ActionResult.Success } + + private companion object { + /** The toast this app last posted. Only ever touched on the main thread. */ + var onScreen: Toast? = null + } } diff --git a/app/src/main/java/com/nexflow/shortcut/ShortcutSyncManager.kt b/app/src/main/java/com/nexflow/shortcut/ShortcutSyncManager.kt index eb9dfe3..64d5cfc 100644 --- a/app/src/main/java/com/nexflow/shortcut/ShortcutSyncManager.kt +++ b/app/src/main/java/com/nexflow/shortcut/ShortcutSyncManager.kt @@ -51,13 +51,16 @@ class ShortcutSyncManager @Inject constructor( val flowMap = flows.associateBy { it.id } val recent = logs.map { it.flowId } .distinct() - .take(4) .mapNotNull { flowMap[it] } - recent to flows - }.collect { (recentFlows, allFlows) -> + // The launcher's long-press menu holds a handful; the widget is however big the + // user made it, and recent runs alone left a large one half empty. Top it up with + // the rest of the flows so the grid has something to fill itself with. + val widgetFlows = (recent + flows).distinctBy { it.id }.take(NexFlowWidget.MAX_FLOWS) + Triple(recent.take(SHORTCUT_LIMIT), flows, widgetFlows) + }.collect { (recentFlows, allFlows, widgetFlows) -> syncShortcuts(recentFlows) refreshPinnedShortcuts(allFlows) - NexFlowWidget.updateRecentFlows(context, recentFlows) + NexFlowWidget.updateRecentFlows(context, widgetFlows) } } } @@ -113,4 +116,9 @@ class ShortcutSyncManager @Inject constructor( .setIntent(PinShortcutHelper.buildIntent(context, flow.id)) private fun Flow.signature(): String = "$id:$name:$icon:$iconColor" + + private companion object { + /** How many recent flows the launcher's long-press menu gets. */ + const val SHORTCUT_LIMIT = 4 + } } diff --git a/app/src/main/java/com/nexflow/ui/ai/AiChatScreen.kt b/app/src/main/java/com/nexflow/ui/ai/AiChatScreen.kt index 7a1d1a4..bff417c 100644 --- a/app/src/main/java/com/nexflow/ui/ai/AiChatScreen.kt +++ b/app/src/main/java/com/nexflow/ui/ai/AiChatScreen.kt @@ -449,6 +449,17 @@ fun AiChatScreen( placementSpec = MaterialTheme.motionScheme.defaultSpatialSpec(), fadeOutSpec = MaterialTheme.motionScheme.defaultEffectsSpec(), ) + // Same, minus the fade out. Retry deletes the error bubble and + // starts the turn in the same breath, so the thinking indicator + // is placed in the very slot the error is still fading out of — + // two opaque bubbles drawn on top of each other. Nothing else in + // this list is ever replaced in place, so only this one bubble + // has to leave immediately. + val replacedItemModifier = Modifier.animateItem( + fadeInSpec = MaterialTheme.motionScheme.defaultEffectsSpec(), + placementSpec = MaterialTheme.motionScheme.defaultSpatialSpec(), + fadeOutSpec = null, + ) // A day rule opens the transcript and marks every date change after it. val previous = uiState.messages.getOrNull(index - 1) if (previous == null || !isSameDay(previous.timestamp, message.timestamp)) { @@ -490,7 +501,7 @@ fun AiChatScreen( } else { null }, - modifier = itemModifier, + modifier = replacedItemModifier, ) is ChatMessage.FlowPreview -> FlowPreviewCard( @@ -507,11 +518,19 @@ fun AiChatScreen( // forming in place, or — before the first token — the indicator. if (uiState.isThinking) { item(key = "thinking") { + // Fades in with the list's own item animation like every + // other bubble; leaves at once, because the answer it turns + // into is placed in this same slot. + val indicatorModifier = Modifier.animateItem( + fadeInSpec = MaterialTheme.motionScheme.defaultEffectsSpec(), + placementSpec = MaterialTheme.motionScheme.defaultSpatialSpec(), + fadeOutSpec = null, + ) val streaming = uiState.streamingText if (streaming.isNullOrBlank()) { - ThinkingBubble(step = uiState.thinkingStep) + ThinkingBubble(step = uiState.thinkingStep, modifier = indicatorModifier) } else { - StreamingBubble(text = streaming) + StreamingBubble(text = streaming, modifier = indicatorModifier) } } } @@ -1187,9 +1206,9 @@ private fun JumpToLatestPill(onClick: () -> Unit) { * frequently" case the accessibility guidance warns about. The finished message announces once. */ @Composable -private fun StreamingBubble(text: String) { +private fun StreamingBubble(text: String, modifier: Modifier = Modifier) { Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(end = bubbleGutter()), horizontalAlignment = Alignment.Start, @@ -1501,10 +1520,11 @@ private fun copyToClipboard(context: Context, text: String) { */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun ThinkingBubble(step: AiChatOrchestrator.Progress?) { +private fun ThinkingBubble(step: AiChatOrchestrator.Progress?, modifier: Modifier = Modifier) { Surface( color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = RoundedCornerShape(20.dp), + modifier = modifier, ) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/com/nexflow/ui/flows/FlowsScreen.kt b/app/src/main/java/com/nexflow/ui/flows/FlowsScreen.kt index 319c3c2..4a98a47 100644 --- a/app/src/main/java/com/nexflow/ui/flows/FlowsScreen.kt +++ b/app/src/main/java/com/nexflow/ui/flows/FlowsScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.animation.scaleOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -43,6 +44,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.size @@ -51,6 +53,8 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons @@ -115,6 +119,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -132,6 +139,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.roundToIntRect import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider @@ -567,11 +575,18 @@ private fun ServiceCapsule( MaterialTheme.colorScheme.onSurfaceVariant var showRunning by remember { mutableStateOf(false) } + var anchorInWindow by remember { mutableStateOf(IntRect.Zero) } val longPressLabel = stringResource(R.string.flows_running_status) - // The popup anchors to this Box, not to the Surface: the Surface's own size animates as the - // service-state label appears, and an anchor that moves would drag the popup with it. - Box(modifier = modifier.padding(end = 8.dp)) { + // The panel lives in a window-filling popup (so it can dim what is behind it), which means + // it cannot anchor itself — it has to be told where the capsule ended up. This Box, not the + // Surface: the Surface's own size animates as the service-state label appears, and an anchor + // that moves would drag the panel with it. + Box( + modifier = modifier + .padding(end = 8.dp) + .onGloballyPositioned { anchorInWindow = it.boundsInWindow().roundToIntRect() }, + ) { Surface( shape = CircleShape, color = containerColor, @@ -624,6 +639,7 @@ private fun ServiceCapsule( RunningFlowsIsland( running = runningFlows, + anchorInWindow = anchorInWindow, expanded = showRunning, onDismiss = { showRunning = false }, ) @@ -656,13 +672,21 @@ private fun RunningCountBadge(count: Int, modifier: Modifier = Modifier) { } /** - * The running-flow list that drops out of the capsule on a long press — a status readout, not a - * menu, so it is a plain [Popup] rather than a dropdown: nothing in it is tappable and it never - * takes over the screen. + * The running-flow panel that drops out of the capsule on a long press — a status readout, not a + * menu, so nothing in it is tappable and it never takes over the screen. + * + * The popup window fills the whole app window rather than wrapping the panel. A popup window + * sized to its content leaves no room outside the surface for the surface's own drop shadow, so + * the shadow is cut off square at the window edge — which is what the panel looked like in light + * theme. A window-sized popup also gives the panel a scrim to sit on. + * + * @param anchorInWindow where the capsule ended up, in window coordinates — the same space + * this popup's content is laid out in, since the window is placed at the window origin. */ @Composable private fun RunningFlowsIsland( running: List, + anchorInWindow: IntRect, expanded: Boolean, onDismiss: () -> Unit, ) { @@ -673,12 +697,11 @@ private fun RunningFlowsIsland( LaunchedEffect(expanded) { transitionState.targetState = expanded } if (!transitionState.currentState && !transitionState.targetState && transitionState.isIdle) return - val density = LocalDensity.current - val positionProvider = remember(density) { - with(density) { IslandPositionProvider(gapPx = 8.dp.roundToPx(), marginPx = 12.dp.roundToPx()) } - } val spatial = MaterialTheme.motionScheme.defaultSpatialSpec() val effects = MaterialTheme.motionScheme.defaultEffectsSpec() + val panelTop = with(LocalDensity.current) { anchorInWindow.bottom.coerceAtLeast(0).toDp() } + 8.dp + val positionProvider = remember(anchorInWindow) { WindowOriginPositionProvider(anchorInWindow) } + val dismiss by rememberUpdatedState(onDismiss) Popup( popupPositionProvider = positionProvider, @@ -686,21 +709,49 @@ private fun RunningFlowsIsland( // focusable so the back gesture dismisses the popup instead of the screen behind it. properties = PopupProperties(focusable = true), ) { - AnimatedVisibility( - visibleState = transitionState, - // Grow out of the capsule's trailing edge, which is where the press happened. - enter = fadeIn(effects) + scaleIn(spatial, initialScale = 0.8f, transformOrigin = IslandOrigin), - exit = fadeOut(effects) + scaleOut(spatial, targetScale = 0.8f, transformOrigin = IslandOrigin), - label = "running_island", - ) { - RunningFlowsIslandContent(running) + Box(modifier = Modifier.fillMaxSize()) { + AnimatedVisibility( + visibleState = transitionState, + enter = fadeIn(effects), + exit = fadeOut(effects), + label = "running_scrim", + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = ScrimAlpha)) + // pointerInput, not clickable: tapping away is not a control, and a + // window-sized clickable would offer itself to TalkBack as one. + // Keyed on Unit with the callback read through rememberUpdatedState: + // keying on the lambda itself would tear down and restart the gesture + // detector on every recomposition. + .pointerInput(Unit) { detectTapGestures { dismiss() } }, + ) + } + AnimatedVisibility( + visibleState = transitionState, + // Grow out of the capsule's trailing edge, which is where the press happened. + enter = fadeIn(effects) + scaleIn(spatial, initialScale = 0.85f, transformOrigin = IslandOrigin), + exit = fadeOut(effects) + scaleOut(spatial, targetScale = 0.85f, transformOrigin = IslandOrigin), + // Hangs just under the capsule, kept clear of the window edges so a long flow + // name can never push it off screen — and so the shadow has room to fall. + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = panelTop, start = 16.dp, end = 16.dp, bottom = 24.dp), + label = "running_island", + ) { + RunningFlowsIslandContent(running) + } } } } -/** Top-trailing corner: the popup hangs from the capsule, so that is what it scales out of. */ +/** Top-trailing corner: the panel hangs from the capsule, so that is what it scales out of. */ private val IslandOrigin = TransformOrigin(pivotFractionX = 1f, pivotFractionY = 0f) +/** M3's scrim opacity for a modal surface. */ +private const val ScrimAlpha = 0.32f + @Composable private fun RunningFlowsIslandContent(running: List) { Surface( @@ -708,56 +759,70 @@ private fun RunningFlowsIslandContent(running: List) { color = MaterialTheme.colorScheme.surfaceContainerHigh, contentColor = MaterialTheme.colorScheme.onSurface, shadowElevation = 6.dp, - // The list grows and shrinks as flows come and go while the popup is open; resize it + // The list grows and shrinks as flows come and go while the panel is open; resize it // rather than letting the window jump between sizes. modifier = Modifier - .sizeIn(minWidth = 200.dp, maxWidth = 300.dp) + .sizeIn(minWidth = 260.dp, maxWidth = 360.dp) .animateContentSize(animationSpec = MaterialTheme.motionScheme.defaultSpatialSpec()), ) { Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Icon( - Icons.Filled.Bolt, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + Icon( + Icons.Filled.Bolt, + contentDescription = null, + modifier = Modifier.padding(7.dp).size(18.dp), + ) + } Text( stringResource(R.string.flows_running_title), - style = MaterialTheme.typography.titleSmall, + style = MaterialTheme.typography.titleMedium, ) } if (running.isEmpty()) { Text( stringResource(R.string.flows_running_none), - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - running.forEach { flow -> - key(flow.id) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.primary, - trackColor = Color.Transparent, - strokeWidth = 2.dp, - modifier = Modifier.size(14.dp), - ) - Text( - text = flow.name, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Column( + // Bounded: a burst of concurrent runs scrolls inside the panel instead of + // growing it past the bottom of the screen. + modifier = Modifier + .heightIn(max = 280.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + running.forEach { flow -> + key(flow.id) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + trackColor = Color.Transparent, + strokeWidth = 2.dp, + modifier = Modifier.size(16.dp), + ) + Text( + text = flow.name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } @@ -767,30 +832,21 @@ private fun RunningFlowsIslandContent(running: List) { } /** - * Hangs the popup under the capsule and flush with its trailing edge, kept [marginPx] clear of - * the window edges so a long flow name can never push it off screen. + * Places the popup window at the app window's origin, so the popup's content is laid out in + * window coordinates and [Modifier.fillMaxSize] inside it covers the window. + * + * The offset is the difference between the anchor as the popup sees it and [anchorInWindow], the + * same anchor in window coordinates — which lands on the window origin whichever space the popup + * is positioning in, and so survives the window not starting at the top-left of the screen + * (split screen, freeform). */ -private class IslandPositionProvider( - private val gapPx: Int, - private val marginPx: Int, -) : PopupPositionProvider { +private class WindowOriginPositionProvider(private val anchorInWindow: IntRect) : PopupPositionProvider { override fun calculatePosition( anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize, - ): IntOffset { - val preferredX = if (layoutDirection == LayoutDirection.Ltr) { - anchorBounds.right - popupContentSize.width - } else { - anchorBounds.left - } - val maxX = (windowSize.width - popupContentSize.width - marginPx).coerceAtLeast(marginPx) - return IntOffset( - x = preferredX.coerceIn(marginPx, maxX), - y = anchorBounds.bottom + gapPx, - ) - } + ): IntOffset = anchorBounds.topLeft - anchorInWindow.topLeft } /** diff --git a/app/src/main/java/com/nexflow/ui/flows/detail/FlowDetailScreen.kt b/app/src/main/java/com/nexflow/ui/flows/detail/FlowDetailScreen.kt index c14f5ea..aeeb037 100644 --- a/app/src/main/java/com/nexflow/ui/flows/detail/FlowDetailScreen.kt +++ b/app/src/main/java/com/nexflow/ui/flows/detail/FlowDetailScreen.kt @@ -78,12 +78,14 @@ import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.ArrowDropDown import androidx.compose.material.icons.outlined.Code +import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.DataObject import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Edit @@ -185,7 +187,6 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel @@ -238,9 +239,13 @@ fun FlowDetailScreen( vm: FlowDetailViewModel = hiltViewModel(), onBack: () -> Unit, onEditWithAi: () -> Unit = {}, + onOpenFlow: (String) -> Unit = {}, ) { val flow by vm.flow.collectAsState() + // Duplicating opens the copy, so what the user edits next is the copy and not the original. + LaunchedEffect(vm) { vm.navigateToCopy.collect { flowId -> onOpenFlow(flowId) } } + // Crossfade loading → editor. contentKey limits the transition to the null ↔ loaded // switch, so ordinary flow edits (every save changes the state) don't re-animate. val fadeSpec = MaterialTheme.motionScheme.defaultEffectsSpec() @@ -463,15 +468,45 @@ private fun FlowDetailContent( Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) } }, + // One overflow, not a row of icons. Every icon here is a whole flow name's + // worth of title taken away, and the name is the only thing on this screen that + // says which flow is being edited — with three of them, a duplicate could not be + // told from its original. None of these three is used often enough to earn that. actions = { - IconButton(onClick = onPinShortcut) { - Icon( - Icons.AutoMirrored.Outlined.AddToHomeScreen, - contentDescription = stringResource(R.string.fd_pin_shortcut), - ) + // stringResource, not context.getString: a resource read through + // LocalContext is not invalidated when the Configuration changes. + val copyName = stringResource(R.string.fd_copy_name, f.name) + var menuOpen by remember { mutableStateOf(false) } + IconButton(onClick = { menuOpen = true }) { + Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.action_more)) } - IconButton(onClick = { showRenameDialog = true }) { - Icon(Icons.Outlined.Edit, contentDescription = stringResource(R.string.fd_edit_flow)) + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.fd_edit_flow)) }, + leadingIcon = { Icon(Icons.Outlined.Edit, contentDescription = null) }, + onClick = { + menuOpen = false + showRenameDialog = true + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.fd_duplicate_flow)) }, + leadingIcon = { Icon(Icons.Outlined.ContentCopy, contentDescription = null) }, + onClick = { + menuOpen = false + vm.duplicateFlow(copyName) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.fd_pin_shortcut)) }, + leadingIcon = { + Icon(Icons.AutoMirrored.Outlined.AddToHomeScreen, contentDescription = null) + }, + onClick = { + menuOpen = false + onPinShortcut() + }, + ) } }, ) @@ -1275,17 +1310,22 @@ private fun GroupedItem( // Highlight ON snaps so the "currently executing" marker tracks the engine with zero // lag; highlight OFF eases out on the effects token (M3: color/elevation = effects). val colorEffects = MaterialTheme.motionScheme.defaultEffectsSpec() - val dpEffects = MaterialTheme.motionScheme.defaultEffectsSpec() + // A container role and its matching "on" colour, not primary at 18% alpha over the row. + // The translucent version let the row's 8dp shadow show straight through the surface — a + // grey smear under the text that read as two overlapping cards in light theme, and was + // invisible in dark theme, which is why it survived this long. It also left the text on + // onSurface over a blue wash. M3 states are colour roles, not alpha and shadow. val surfaceColor by animateColorAsState( - targetValue = if (highlighted) primary.copy(alpha = 0.18f) + targetValue = if (highlighted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainer, animationSpec = if (highlighted) snap() else colorEffects, label = "item_bg", ) - val shadowElevation by animateDpAsState( - targetValue = if (highlighted) 8.dp else 0.dp, - animationSpec = if (highlighted) snap() else dpEffects, - label = "item_shadow", + val contentColor by animateColorAsState( + targetValue = if (highlighted) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface, + animationSpec = if (highlighted) snap() else colorEffects, + label = "item_fg", ) val borderColor by animateColorAsState( targetValue = if (highlighted) primary else androidx.compose.ui.graphics.Color.Transparent, @@ -1315,14 +1355,17 @@ private fun GroupedItem( onClick = onClick, shape = shape, color = surfaceColor, - shadowElevation = maxOf(shadowElevation, dragElevation), + contentColor = contentColor, + // Only a drag lifts a row now; being the executing row is said in colour. + shadowElevation = dragElevation, modifier = rowModifier, ) { content() } } else { Surface( shape = shape, color = surfaceColor, - shadowElevation = maxOf(shadowElevation, dragElevation), + contentColor = contentColor, + shadowElevation = dragElevation, modifier = rowModifier, ) { content() } } diff --git a/app/src/main/java/com/nexflow/ui/flows/detail/FlowDetailViewModel.kt b/app/src/main/java/com/nexflow/ui/flows/detail/FlowDetailViewModel.kt index 9daa9c0..647871d 100644 --- a/app/src/main/java/com/nexflow/ui/flows/detail/FlowDetailViewModel.kt +++ b/app/src/main/java/com/nexflow/ui/flows/detail/FlowDetailViewModel.kt @@ -255,6 +255,40 @@ class FlowDetailViewModel @Inject constructor( copy(variables = variables.filter { it.name != name }) } + private val _navigateToCopy = MutableSharedFlow(extraBufferCapacity = 1) + + /** The id of a flow just created by [duplicateFlow] — the screen follows it to the copy. */ + val navigateToCopy: SharedFlow = _navigateToCopy.asSharedFlow() + + /** + * Saves a second flow holding everything this one holds, under [copyName]. + * + * The copy is created switched off. A copy of an enabled flow would otherwise subscribe its + * triggers the moment it was saved, and the user would have two flows doing the same thing + * before they had touched anything — including two of the same alarm or geofence. + */ + fun duplicateFlow(copyName: String) { + viewModelScope.launch { + val original = flow.value ?: return@launch + val now = System.currentTimeMillis() + // Fresh ids all the way down: a trigger's id is what the engine registers real system + // resources under (a geofence request, an alarm), so two flows carrying the same + // trigger id would fight over one registration. + val copy = original.copy( + id = java.util.UUID.randomUUID().toString(), + name = copyName, + enabled = false, + createdAt = now, + updatedAt = now, + triggers = original.triggers.map { it.copy(id = java.util.UUID.randomUUID().toString()) }, + conditions = original.conditions.map { it.copy(id = java.util.UUID.randomUUID().toString()) }, + actions = original.actions.map { it.copy(id = java.util.UUID.randomUUID().toString()) }, + ) + repository.save(copy) + _navigateToCopy.emit(copy.id) + } + } + fun setEnabled(enabled: Boolean) { viewModelScope.launch { // Block enabling while required permissions are missing — otherwise the engine diff --git a/app/src/main/java/com/nexflow/ui/navigation/AppNavigation.kt b/app/src/main/java/com/nexflow/ui/navigation/AppNavigation.kt index 3496748..00bde85 100644 --- a/app/src/main/java/com/nexflow/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/nexflow/ui/navigation/AppNavigation.kt @@ -186,6 +186,8 @@ fun NexFlowNavHost(navController: NavHostController, modifier: Modifier = Modifi val flowId = entry.arguments?.getString("flowId") ?: return@FlowDetailScreen navController.navigate("ai_chat?flowId=$flowId") }, + // The original stays on the back stack, so Back from a copy returns to it. + onOpenFlow = { flowId -> navController.navigate(flowRoute(flowId, focusItemId = null)) }, ) } composable("about") { diff --git a/app/src/main/java/com/nexflow/widget/NexFlowWidget.kt b/app/src/main/java/com/nexflow/widget/NexFlowWidget.kt index 0c0a8f7..77670ef 100644 --- a/app/src/main/java/com/nexflow/widget/NexFlowWidget.kt +++ b/app/src/main/java/com/nexflow/widget/NexFlowWidget.kt @@ -3,6 +3,7 @@ package com.nexflow.widget import android.content.Context import android.content.Intent import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.datastore.preferences.core.intPreferencesKey @@ -10,16 +11,16 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.glance.GlanceId import androidx.glance.GlanceModifier import androidx.glance.GlanceTheme +import androidx.glance.LocalSize import androidx.glance.Image import androidx.glance.ImageProvider import androidx.glance.action.actionStartActivity import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.actionStartService import androidx.glance.appwidget.appWidgetBackground import androidx.glance.appwidget.cornerRadius -import androidx.glance.appwidget.lazy.LazyColumn -import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.background @@ -53,6 +54,11 @@ class NexFlowWidget : GlanceAppWidget() { override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition + // Exact, not Responsive: the layout is a grid whose column and row counts come out of the + // real size, and no set of breakpoint sizes can tell a three-column widget from a four-column + // one. Exact costs a re-provide on each resize, which is when the grid has to reflow anyway. + override val sizeMode = SizeMode.Exact + override suspend fun provideGlance(context: Context, id: GlanceId) { provideContent { Content(context) } } @@ -62,7 +68,29 @@ class NexFlowWidget : GlanceAppWidget() { val prefs = currentState() val enabled = prefs[KEY_ENABLED] ?: 0 val total = prefs[KEY_TOTAL] ?: 0 - val recentFlows = decodeFlows(prefs[KEY_RECENT_FLOWS]) + val flows = decodeFlows(prefs[KEY_RECENT_FLOWS]) + + val size = LocalSize.current + // The header is the first thing to go: on a one-row widget every dp belongs to the + // flows, and "NexFlow" over two cards is the launcher telling the user what they can + // already see. It comes back as soon as there is room for it. + val showHeader = size.height >= HEADER_MIN_HEIGHT + val outerPadding = if (showHeader) 16.dp else 10.dp + val contentWidth = size.width - outerPadding * 2 + // Columns out of how wide a card is allowed to get, not out of fixed screen widths. A + // phone's home screen is around 400dp across, so a "four columns past 430dp" rule was a + // layout nobody could reach, while "two columns past 170dp" handed out 69dp cards that + // could not hold a name. Adding a column only when another card actually fits keeps every + // width the user can drag to a width that reads, on a phone and on a tablet alike. + val columns = ((contentWidth + CARD_GAP) / (MIN_CARD_WIDTH + CARD_GAP)).toInt() + .coerceIn(1, MAX_COLUMNS) + val contentHeight = size.height - outerPadding * 2 - if (showHeader) HEADER_HEIGHT else 0.dp + // Whole rows only, then stretched to fill what is left over. The launcher clips a widget + // that overflows — it does not scale it — so a row that half fits is a row cut in half, + // and a fixed card height in a tall widget is a band of empty background at the bottom. + val rows = ((contentHeight + CARD_GAP) / (IDEAL_CARD_HEIGHT + CARD_GAP)).toInt().coerceAtLeast(1) + val cardHeight = ((contentHeight - CARD_GAP * (rows - 1)) / rows) + .coerceIn(MIN_CARD_HEIGHT, MAX_CARD_HEIGHT) GlanceTheme { Column( @@ -71,35 +99,44 @@ class NexFlowWidget : GlanceAppWidget() { .appWidgetBackground() .background(GlanceTheme.colors.widgetBackground) .cornerRadius(24.dp) - .padding(16.dp), + .padding(outerPadding), ) { - // Header row — tapping it opens the app. - Row( - modifier = GlanceModifier - .fillMaxWidth() - .padding(bottom = 12.dp) - .clickable(actionStartActivity()), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "NexFlow", - style = TextStyle( - color = GlanceTheme.colors.onSurface, - fontWeight = FontWeight.Bold, - fontSize = 16.sp, - ), - modifier = GlanceModifier.defaultWeight(), - ) - Text( - text = "$enabled/$total", - style = TextStyle( - color = GlanceTheme.colors.onSurfaceVariant, - fontSize = 12.sp, - ), - ) + if (showHeader) { + // Tapping the header opens the app. + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(bottom = HEADER_GAP) + .clickable(actionStartActivity()), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "NexFlow", + style = TextStyle( + color = GlanceTheme.colors.onSurface, + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + ), + modifier = GlanceModifier.defaultWeight(), + ) + // "2/22" on its own said nothing to anyone who had not written it. The + // label is what makes the pair of numbers a fact about the flows. + Text( + text = context.getString(R.string.widget_enabled_count, enabled, total), + style = TextStyle( + color = GlanceTheme.colors.onSecondaryContainer, + fontSize = 11.sp, + ), + maxLines = 1, + modifier = GlanceModifier + .background(GlanceTheme.colors.secondaryContainer) + .cornerRadius(10.dp) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) + } } - if (recentFlows.isEmpty()) { + if (flows.isEmpty()) { Box( modifier = GlanceModifier .fillMaxSize() @@ -112,26 +149,27 @@ class NexFlowWidget : GlanceAppWidget() { ) } } else { - // Two cards per row, built as a LazyColumn of pairs rather than with - // LazyVerticalGrid: the grid is still @ExperimentalGlanceApi in 1.1.1, and - // its GridCells.Adaptive is @RequiresApi(31) while minSdk here is 30. Rows of - // pairs give the same grid and the same scrolling out of stable API. - LazyColumn(modifier = GlanceModifier.fillMaxSize()) { - items(recentFlows.chunked(COLUMNS)) { pair -> - Column { - Row(modifier = GlanceModifier.fillMaxWidth()) { - pair.forEachIndexed { index, flow -> - if (index > 0) Spacer(modifier = GlanceModifier.width(CARD_GAP)) - FlowCard(context, flow, GlanceModifier.defaultWeight()) - } - // Keeps a lone trailing card at one column's width instead of - // letting it stretch across the row and break the grid. - repeat(COLUMNS - pair.size) { - Spacer(modifier = GlanceModifier.width(CARD_GAP)) - Spacer(modifier = GlanceModifier.defaultWeight()) - } + // A plain Column, not a LazyColumn: the lazy one is a RemoteViews collection + // backed by a ListView, whose adapter reloads asynchronously — so every + // resize showed an empty widget for a moment while the launcher waited for + // the rows, and left a scrollbar down the side. The grid already works out + // exactly what fits, so there is nothing to scroll and everything can travel + // in the one RemoteViews and land with the resize. + val chunks = flows.take(rows * columns).chunked(columns) + Column(modifier = GlanceModifier.fillMaxSize()) { + chunks.forEachIndexed { rowIndex, chunk -> + if (rowIndex > 0) Spacer(modifier = GlanceModifier.height(CARD_GAP)) + Row(modifier = GlanceModifier.fillMaxWidth()) { + chunk.forEachIndexed { index, flow -> + if (index > 0) Spacer(modifier = GlanceModifier.width(CARD_GAP)) + FlowCard(context, flow, cardHeight, GlanceModifier.defaultWeight()) + } + // Keeps a lone trailing card at one column's width instead of + // letting it stretch across the row and break the grid. + repeat(columns - chunk.size) { + Spacer(modifier = GlanceModifier.width(CARD_GAP)) + Spacer(modifier = GlanceModifier.defaultWeight()) } - Spacer(modifier = GlanceModifier.height(CARD_GAP)) } } } @@ -143,57 +181,102 @@ class NexFlowWidget : GlanceAppWidget() { /** * One flow as a card: its icon on its own color, its name, and what starts it. Tapping * anywhere on the card runs the flow. + * + * [height] is what the grid worked out this row can have, and it decides how much of the card + * there is room to draw: a short widget gets the icon beside the name on one line, a taller + * one stacks them and adds what triggers the flow. */ @Composable - private fun FlowCard(context: Context, flow: WidgetFlow, modifier: GlanceModifier) { - Column( - modifier = modifier - .background(GlanceTheme.colors.surfaceVariant) - .cornerRadius(20.dp) - .clickable( - actionStartService( - Intent(context, FlowExecutionService::class.java).apply { - action = FlowExecutionService.ACTION_RUN_FLOW - putExtra(FlowExecutionService.EXTRA_FLOW_ID, flow.id) - }, - isForegroundService = true, - ), - ) - .padding(10.dp), - ) { - Image( - provider = ImageProvider( - FlowShortcutIcon.renderBadge(context, flow.icon, flow.color, BADGE_DP), + private fun FlowCard( + context: Context, + flow: WidgetFlow, + height: Dp, + modifier: GlanceModifier, + ) { + val compact = height < COMPACT_CARD_HEIGHT + val card = modifier + .height(height) + .background(GlanceTheme.colors.surfaceVariant) + .cornerRadius(if (compact) 16.dp else 20.dp) + .clickable( + actionStartService( + Intent(context, FlowExecutionService::class.java).apply { + action = FlowExecutionService.ACTION_RUN_FLOW + putExtra(FlowExecutionService.EXTRA_FLOW_ID, flow.id) + }, + isForegroundService = true, ), - // The card's own name follows immediately; announcing the icon as well would - // read every flow out twice. - contentDescription = null, - modifier = GlanceModifier.size(BADGE_DP.dp), ) - Spacer(modifier = GlanceModifier.height(6.dp)) - Text( - text = flow.name, - style = TextStyle( - color = GlanceTheme.colors.onSurface, - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - ), - maxLines = 2, - ) - if (flow.subtitle.isNotEmpty()) { - Spacer(modifier = GlanceModifier.height(2.dp)) + + if (compact) { + // Sized off the row so the icon and the card's own padding always add up to less + // than the height the grid handed this card. + val badge = (height - 16.dp).coerceIn(16.dp, 28.dp) + Row( + modifier = card.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Badge(context, flow, badge) + Spacer(modifier = GlanceModifier.width(10.dp)) Text( - text = flow.subtitle, + text = flow.name, style = TextStyle( - color = GlanceTheme.colors.onSurfaceVariant, - fontSize = 11.sp, + color = GlanceTheme.colors.onSurface, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, ), maxLines = 1, + modifier = GlanceModifier.defaultWeight(), + ) + } + } else { + // Each line only goes in if the row is tall enough to hold it; otherwise the card + // would draw text the launcher then clips off at the bottom edge. + val nameLines = if (height >= TWO_LINE_MIN_HEIGHT) 2 else 1 + // Which flow this is beats what triggers it, so the second line of the name is bought + // before the subtitle is: a card tall enough for one but not both drops the subtitle. + val subtitleFloor = if (nameLines == 2) SUBTITLE_WITH_TWO_LINES else SUBTITLE_MIN_HEIGHT + val showSubtitle = height >= subtitleFloor && flow.subtitle.isNotEmpty() + Column(modifier = card.padding(12.dp)) { + Badge(context, flow, BADGE_SIZE) + Spacer(modifier = GlanceModifier.height(8.dp)) + Text( + text = flow.name, + style = TextStyle( + color = GlanceTheme.colors.onSurface, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ), + maxLines = nameLines, ) + if (showSubtitle) { + Spacer(modifier = GlanceModifier.height(2.dp)) + Text( + text = flow.subtitle, + style = TextStyle( + color = GlanceTheme.colors.onSurfaceVariant, + fontSize = 11.sp, + ), + maxLines = 1, + ) + } } } } + @Composable + private fun Badge(context: Context, flow: WidgetFlow, size: Dp) { + Image( + provider = ImageProvider( + FlowShortcutIcon.renderBadge(context, flow.icon, flow.color, size.value), + ), + // The card's own name follows immediately; announcing the icon as well would + // read every flow out twice. + contentDescription = null, + modifier = GlanceModifier.size(size), + ) + } + /** * What one card needs to draw itself. Held in widget state rather than read from the * database, because [provideGlance] can be called by the launcher at any time, including @@ -214,11 +297,40 @@ class NexFlowWidget : GlanceAppWidget() { val KEY_TOTAL = intPreferencesKey("total_count") val KEY_RECENT_FLOWS = stringPreferencesKey("recent_flows") - private const val COLUMNS = 2 - private val CARD_GAP = 8.dp + private val CARD_GAP = 10.dp + + /** Narrowest a card may be before the grid drops back to fewer columns. */ + private val MIN_CARD_WIDTH = 92.dp + + /** Four columns needs roughly a 600dp screen — a tablet or an unfolded foldable. */ + private const val MAX_COLUMNS = 4 + + /** Height a card is given when the widget has room for it to be comfortable. */ + private val IDEAL_CARD_HEIGHT = 104.dp + private val MIN_CARD_HEIGHT = 28.dp + private val MAX_CARD_HEIGHT = 132.dp - /** Badge diameter; also the size its bitmap is rasterised at. */ - private const val BADGE_DP = 32f + /** Below this a card puts its icon beside the name instead of above it. */ + private val COMPACT_CARD_HEIGHT = 84.dp + private val SUBTITLE_MIN_HEIGHT = 98.dp + private val SUBTITLE_WITH_TWO_LINES = 119.dp + private val TWO_LINE_MIN_HEIGHT = 102.dp + + /** Below this the header is dropped and the whole widget is flows. */ + private val HEADER_MIN_HEIGHT = 150.dp + private val HEADER_GAP = 12.dp + + /** Header row height plus [HEADER_GAP] — what the grid has to budget for it. */ + private val HEADER_HEIGHT = 34.dp + + /** Badge diameter on a stacked card; also the size its bitmap is rasterised at. */ + private val BADGE_SIZE = 32.dp + + /** + * How many flows to keep in widget state. Not how many are shown — that is whatever the + * grid works out the widget's current size fits, and the rest stay one scroll away. + */ + const val MAX_FLOWS = 12 private val json = Json { ignoreUnknownKeys = true } diff --git a/app/src/main/res/layout/widget_preview_overview.xml b/app/src/main/res/layout/widget_preview_overview.xml index 04e2a0a..cb0affe 100644 --- a/app/src/main/res/layout/widget_preview_overview.xml +++ b/app/src/main/res/layout/widget_preview_overview.xml @@ -8,7 +8,7 @@ android:layout_height="match_parent" android:orientation="vertical" android:background="@drawable/bg_widget_preview" - android:padding="14dp"> + android:padding="16dp"> + android:padding="12dp"> + android:padding="12dp"> + android:padding="12dp"> + android:padding="12dp"> フローを選択 フローがありません — まずアプリ内で作成してください 実行履歴がありません + %1$d/%2$d 有効 + 4/6 有効 自動化を実行中 @@ -50,6 +52,8 @@ アイコンを変更 フローを編集 + フローを複製 + %1$s のコピー ホーム画面に追加 このランチャーはショートカットの固定に対応していません フローを開始しました @@ -798,6 +802,7 @@ 元に戻す + その他のオプション 「%1$s」を削除しました diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2fa6836..8fd80f6 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -9,6 +9,8 @@ 选择流程 尚无流程 — 请先在 App 内创建 尚无执行记录 + %1$d/%2$d 已启用 + 4/6 已启用 自动化运行中 @@ -50,6 +52,8 @@ 更改图标 编辑流程 + 复制流程 + %1$s 的副本 添加到主屏幕 此启动器不支持固定快捷方式 流程已启动 @@ -798,6 +802,7 @@ 撤销 + 更多选项 已删除“%1$s” diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 8ca50a2..918df77 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -9,6 +9,8 @@ 選擇流程 尚無流程 — 請先在 App 內建立 尚無執行記錄 + %1$d/%2$d 已啟用 + 4/6 已啟用 自動化執行中 @@ -50,6 +52,8 @@ 變更圖示 編輯流程 + 複製流程 + %1$s 的副本 新增到主畫面 此啟動器不支援釘選捷徑 流程已啟動 @@ -798,6 +802,7 @@ 復原 + 更多選項 已刪除「%1$s」 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 97908d1..102896e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,6 +11,7 @@ Choose a flow No flows yet — create one in the app first No runs yet + %1$d of %2$d on Automation is active @@ -53,6 +54,8 @@ Change icon Edit flow + Duplicate flow + %1$s copy Add to home screen This launcher does not support pinned shortcuts Flow started @@ -817,6 +820,7 @@ Undo + More options Deleted “%1$s” @@ -857,7 +861,7 @@ Detailed execution log Also record what each action’s settings came out as — the address actually called, the text actually sent. Stored on this device, and it will include whatever your flows handle. Closed without choosing - 4/6 + 4 of 6 on Morning routine Commute mode Focus time diff --git a/app/src/main/res/xml/nexflow_widget_info.xml b/app/src/main/res/xml/nexflow_widget_info.xml index d017b78..429ac1e 100644 --- a/app/src/main/res/xml/nexflow_widget_info.xml +++ b/app/src/main/res/xml/nexflow_widget_info.xml @@ -3,6 +3,8 @@ android:description="@string/widget_overview_description" android:minWidth="245dp" android:minHeight="180dp" + android:minResizeWidth="110dp" + android:minResizeHeight="40dp" android:maxResizeWidth="624dp" android:maxResizeHeight="422dp" android:targetCellWidth="4" From 4054922b40b3262f4ff7093c5a30864a7b3530e9 Mon Sep 17 00:00:00 2001 From: ADSFAaron Date: Thu, 3 Sep 2026 21:02:07 +0800 Subject: [PATCH 4/4] docs(play): store copy for 1.6.0 in all four languages Same four blocks in every language: the two things the user can see (duplicate, the widget), the battery trigger/condition distinction, then the fixes. English runs to 496 of the 500 characters Play allows, so its sentences are cut shorter than the CJK ones rather than a whole block being dropped. The 1.5.1 copy is no longer in the file; the footnote says which commit to read it out of. Co-Authored-By: Claude Opus 5 --- docs/play-release-notes.md | 101 +++++++++++++++++++++---------------- 1 file changed, 58 insertions(+), 43 deletions(-) diff --git a/docs/play-release-notes.md b/docs/play-release-notes.md index c140c36..a33563a 100644 --- a/docs/play-release-notes.md +++ b/docs/play-release-notes.md @@ -9,87 +9,102 @@ --- -# 上架用:1.5.1(versionCode 10) +# 上架用:1.6.0(versionCode 11) -**1.5.0 沒有上架 Play**,所以商店的使用者是從 1.4.0 直接跳到 1.5.1。下面這份文案 -因此涵蓋 1.5.0 與 1.5.1 兩版的內容,不是只有 1.5.1 的部分。 +前一版(1.5.1)的商店文案保留在 git 記錄裡:`git show d92d940:docs/play-release-notes.md`。 -排在最前面的是**排程權限**以外唯一的新功能,而排程權限是這兩版裡唯一 -**需要使用者自己動手授權**才會好的項目——其餘都是安裝完就生效。 +這一版沒有需要使用者動手的項目——安裝完就生效,所以四種語言都直接從新功能開始。 +四件事的順序是:兩個看得到的新東西、一則觀念說明、然後才是修正。 -英文版塞不下音量摘要那一條(500 字元上限,英文最不省字),所以只有中日文保留; -那是四件事裡最小的一件,捨它最不虧。 +英文最不省字:完整版超過 500 字元,所以每一句都比中日文短一截(例如小工具那段 +不重述「拉寬拉高」,修正清單也只留關鍵詞)。四個區塊都保住了。 -## 繁體中文(zh-TW)— 232 字元 +## 繁體中文(zh-TW)— 265 字元 ``` -【看得見正在執行的流程】 -流程執行時,卡片右下角會顯示「執行中」,不論是側滑執行、時間到或背景觸發。通知會寫出正在執行哪一個流程;長按右上角的閃電可列出全部。 +【複製流程】 +流程詳細頁右上角 ⋮ →「複製流程」。副本會以停用狀態建立,等你改好再開啟。 -【排程準時了】 -排定 9:00 卻晚十分鐘才跑,是因為缺少「鬧鐘和提醒」權限。流程卡片現在會提示,並帶你前往授權。 +【小工具會跟著大小變】 +欄數、列數與卡片大小依小工具的實際尺寸調整,拉寬拉高都不會再被切掉半排;能放的流程也從 4 個增加到 12 個。 -【NFC 修好了】 -App 開著時不再獨占感應,網址標籤與交通卡恢復正常;而在你有啟用中的 NFC 流程時,關著 App 感應一樣會觸發。 +【電池:觸發器與條件的差別】 +兩者的設定畫面都加上說明。觸發器是「跨過門檻的那一刻」發生一次,條件是「每次執行前檢查」。 -【其他】 -• 音量動作的摘要不再顯示「?」 +【修正】 +• 淺色模式下執行中的動作不再糊成一團 +• 撥動開關後,流程不會突然跳到清單最上面 +• 快顯訊息不再比流程晚好幾秒才冒出來 +• AI 對話按「重試」時兩個泡泡重疊 ``` --- -## English(en-US)— 483 字元 +## English(en-US)— 496 字元 ``` -SEE WHAT'S RUNNING -A running flow shows "Running" on its card — swiped, scheduled or triggered in the background. The notification names it, and a long press on the lightning bolt lists them all. +DUPLICATE A FLOW +Detail screen, ⋮ → Duplicate flow. The copy starts switched off. -SCHEDULES RUN ON TIME -A flow set for 9:00 could run ten minutes late: the "Alarms & reminders" permission was never requested. Flows now flag it and take you there. +THE WIDGET FITS ITS SIZE +Columns, rows and cards follow the widget's size — no half-cut rows — and it holds 12 flows, not 4. -NFC FIXED -Tags are no longer taken over while the app is open, and work with the app closed once an NFC flow is enabled. +BATTERY: TRIGGER VS CONDITION +The trigger fires once, as the level crosses. The condition is checked before every run. + +FIXED +• Light theme: the running action is readable +• Flows stay put when you flick the switch +• Toasts keep up with the flow +• AI chat: bubbles overlapped after Retry ``` --- -## 日本語(ja-JP)— 289 字元 +## 日本語(ja-JP)— 338 字元 ``` -【実行中のフローが見えます】 -実行中はカード右下に「実行中」と表示されます。スワイプ実行でも、時刻やバックグラウンドのトリガーでも同じです。通知にはフロー名が表示され、右上の稲妻の長押しで一覧できます。 +【フローの複製】 +詳細画面の右上 ⋮ →「フローを複製」。複製は無効の状態で作られるので、編集してから有効にできます。 -【スケジュールが時間どおりに】 -9:00 のフローが10分遅れることがありました。「アラームとリマインダー」の権限を要求していなかったためです。不足時はカードに表示し、設定へ案内します。 +【ウィジェットがサイズに合わせて変化】 +列数・行数・カードの大きさがウィジェットの実際のサイズに追従し、行が途中で切れなくなりました。登録できるフローも 4 件から 12 件に。 -【NFC の修正】 -アプリ起動中にタグを占有しなくなりました。有効な NFC フローがあれば、アプリを閉じていてもタグが動作します。 +【バッテリー:トリガーと条件の違い】 +どちらの設定にも説明を追加しました。トリガーは「しきい値をまたいだ瞬間」に一度、条件は「実行前に毎回」チェックします。 -・音量アクションの概要が「?」にならないように +【修正】 +・ライトテーマで実行中のアクションが読めるように +・スイッチを切り替えてもフローが一番上に飛ばなくなりました +・トーストがフローより数秒遅れて出る問題 +・AI チャットの「再試行」で吹き出しが重なる問題 ``` --- -## 简体中文(zh-CN)— 232 字元 +## 简体中文(zh-CN)— 265 字元 ``` -【看得见正在运行的流程】 -流程运行时,卡片右下角会显示「运行中」,无论是侧滑运行、时间到或后台触发。通知会写出正在运行哪一个流程;长按右上角的闪电可列出全部。 +【复制流程】 +流程详细页右上角 ⋮ →「复制流程」。副本会以停用状态建立,等你改好再开启。 -【定时准时了】 -设定 9:00 却晚十分钟才跑,是因为缺少「闹钟和提醒」权限。流程卡片现在会提示,并带你前往授权。 +【小工具会跟着大小变】 +列数、行数与卡片大小依小工具的实际尺寸调整,拉宽拉高都不会再被切掉半排;能放的流程也从 4 个增加到 12 个。 -【NFC 修好了】 -App 开着时不再独占感应,网址标签与交通卡恢复正常;而在你有启用中的 NFC 流程时,关着 App 感应一样会触发。 +【电池:触发器与条件的差别】 +两者的设置画面都加上说明。触发器是「跨过门槛的那一刻」发生一次,条件是「每次执行前检查」。 -【其他】 -• 音量动作的摘要不再显示「?」 +【修正】 +• 浅色模式下执行中的动作不再糊成一团 +• 拨动开关后,流程不会突然跳到列表最上面 +• 快显消息不再比流程晚好几秒才冒出来 +• AI 对话按「重试」时两个气泡重叠 ``` --- # 附註 -若日後需要分版本的文案(例如補發 GitHub Release),1.5.0 與 1.5.1 各自的四語版本 -保留在 git 記錄裡:`git show 618d26b:docs/play-release-notes.md`(1.5.0)與 -`git show 897abb1:docs/play-release-notes.md`(1.5.0 + 1.5.1 分開版)。 +這份檔案只留「下一次要貼進 Play 的那一版」。舊版文案保留在 git 記錄裡: +`git show d92d940:docs/play-release-notes.md`(1.5.0 + 1.5.1 合併版)、 +`git show 618d26b:docs/play-release-notes.md`(1.5.0)。