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/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/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 のコピー ホーム画面に追加 このランチャーはショートカットの固定に対応していません フローを開始しました @@ -58,9 +62,9 @@ フローをエクスポート エクスポート Gemini で編集 - トリガー - 条件 - アクション + 〜のとき + しかも〜の場合だけ + 実行する 変数 トリガーがありません。下から追加してください。 条件がありません。トリガーが作動するたびにフローが実行されます。 @@ -646,7 +650,7 @@ 繰り返し カスタム曜日 バッテリー残量 - トリガー条件 + トリガーのタイミング 充電状態 デバイス名(任意) ネットワーク名(任意) @@ -690,6 +694,8 @@ スクリーンショットには NexFlow ユーザー補助サービスの有効化が必要です。「設定 → ユーザー補助 → インストール済みアプリ → NexFlow」でオンにしてください。Android 12 以上が必要です。 ネットワーク名の照合には位置情報の権限が必要です。権限がないと Android は名前を隠すため、この条件は決して成立しません。空欄にすると、どの Wi-Fi でも成立します。 検出できるのはオーディオ機器(ヘッドホン、スピーカー、カーオーディオ)だけです。その他の Bluetooth 機器はこの条件では検出できません。 + 残量がこのしきい値を「またいだ」瞬間に一度だけ作動します。低いままでも再度は作動しません。別のタイミングで残量を見たい場合(定時実行など)は、「しかも〜の場合だけ」の「バッテリー残量」条件を使ってください。 + どのトリガーで起動したかに関わらず、実行の直前に毎回チェックされます。これ自体は何も起動しません——「バッテリー」トリガーと違い、その実行を通すか止めるかを決めるだけです。 条件 例:{{battery}} < 20 · {{status}} == ok · true。==、!=、<、>、<=、>= をサポート——数値は数値として比較されます。「もし終了」アクションでブロックを閉じてください。 @@ -760,9 +766,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 @@ -795,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 5e51368..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 的副本 添加到主屏幕 此启动器不支持固定快捷方式 流程已启动 @@ -58,9 +62,9 @@ 导出流程 导出 用 Gemini 修改 - 触发 - 限制条件 - 动作 + 当…的时候 + 而且只有在 + 就执行 变量 尚无触发器,请在下方添加。 尚无限制条件。只要触发器响应,流程就会执行。 @@ -646,7 +650,7 @@ 重复 自定义日期 电池电量 - 触发条件 + 触发时机 充电状态 设备名称(可选) 网络名称(可选) @@ -690,6 +694,8 @@ 屏幕截图需要启用 NexFlow 无障碍服务。请前往“设置 → 无障碍 → 已安装的应用 → NexFlow”并开启。需要 Android 12 及以上。 匹配网络名称需要定位权限 — 没有权限时 Android 会隐藏名称,该条件将永远不成立。留空即接受任意 Wi-Fi 网络。 只能检测音频设备(耳机、音箱、车载音响),其他蓝牙配件无法被该条件检测。 + 只在电量「跨过」这个门槛的那一刻触发一次;电量一直停在门槛以下并不会再次触发。想在别的时机检查电量(例如定时执行时),请改用「而且只有在」区段里的「电池电量」限制条件。 + 不论是哪个触发器叫醒流程,每次执行前都会检查一次当下电量。它本身不会启动任何东西——和「电池」触发器不同,它只决定这次执行放不放行。 条件 示例:{{battery}} < 20 · {{status}} == ok · true。支持 ==、!=、<、>、<=、>=——数字按数值比较。请用“结束如果”动作关闭此块。 @@ -710,8 +716,8 @@ 仅周末 自定义日期… 一次 - 低于 - 高于 + 掉到…以下 + 升到…以上 任意 充电中 未充电 @@ -760,9 +766,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 @@ -795,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 3aa9ef2..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 的副本 新增到主畫面 此啟動器不支援釘選捷徑 流程已啟動 @@ -58,9 +62,9 @@ 匯出流程 匯出 用 Gemini 修改 - 觸發 - 限制條件 - 動作 + 當…的時候 + 而且只有在 + 就執行 變數 尚無觸發器,請在下方新增。 尚無限制條件。只要觸發器響應,流程就會執行。 @@ -646,7 +650,7 @@ 重複 自訂日期 電池電量 - 觸發條件 + 觸發時機 充電狀態 裝置名稱(選填) 網路名稱(選填) @@ -690,6 +694,8 @@ 螢幕截圖需要啟用 NexFlow 無障礙服務。請至「設定 → 無障礙 → 已安裝的應用程式 → NexFlow」並開啟。需要 Android 12 以上。 比對網路名稱需要定位權限 — 沒有權限時 Android 會隱藏名稱,這個條件將永遠不成立。留空即接受任何 Wi-Fi 網路。 只偵測得到音訊裝置(耳機、喇叭、車用音響),其他藍牙配件無法被這個條件偵測。 + 只在電量「跨過」這個門檻的那一刻觸發一次;電量一直停在門檻以下並不會再次觸發。想在別的時機檢查電量(例如定時執行時),請改用「而且只有在」區段裡的「電池電量」限制條件。 + 不論是哪個觸發器叫醒流程,每次執行前都會檢查一次當下電量。它本身不會啟動任何東西——和「電池」觸發器不同,它只決定這次執行放不放行。 條件 範例:{{battery}} < 20 · {{status}} == ok · true。支援 ==、!=、<、>、<=、>=——數字會以數值比較。請以「結束如果」動作關閉此區塊。 @@ -710,8 +716,8 @@ 僅週末 自訂日期… 一次 - 低於 - 高於 + 掉到…以下 + 升到…以上 任何 充電中 未充電 @@ -760,9 +766,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 @@ -795,6 +802,7 @@ 復原 + 更多選項 已刪除「%1$s」 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7203942..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 @@ -705,6 +708,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 +782,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 @@ -814,6 +820,7 @@ Undo + More options Deleted “%1$s” @@ -854,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" 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)。 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.