Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 被獨占」而一併拿掉的背景感應補回來,但這次只發給真的需要的人。
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
10 changes: 8 additions & 2 deletions app/src/main/java/com/nexflow/data/local/dao/FlowDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExecutionStep>) {
Expand Down
12 changes: 11 additions & 1 deletion app/src/main/java/com/nexflow/executor/ToastActionExecutor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
16 changes: 12 additions & 4 deletions app/src/main/java/com/nexflow/shortcut/ShortcutSyncManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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
}
}
32 changes: 26 additions & 6 deletions app/src/main/java/com/nexflow/ui/ai/AiChatScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -490,7 +501,7 @@ fun AiChatScreen(
} else {
null
},
modifier = itemModifier,
modifier = replacedItemModifier,
)

is ChatMessage.FlowPreview -> FlowPreviewCard(
Expand All @@ -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)
}
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading