U.C5 匯出按鈕按下零回饋 — 狀態機完備但 UI 沒接線
book_overview_app 是一個 Flutter 書庫管理教學專案,以下案例取自該專案的實機測試。按鈕背後的業務邏輯與狀態機完全正確,但三層回饋(點擊確認 / 等待指示 / 結果通知)全缺時,使用者的體感等同功能壞掉。
觀察
book_overview_app 的匯出設定頁有一顆「確認匯出」按鈕。實機測試按下後畫面無任何變化:沒有 loading、沒有成功訊息、沒有錯誤訊息。使用者無法判斷三種可能中的哪一種成立:匯出已完成、匯出正在執行、按鈕根本沒接線。
翻開程式碼,真相是第三種 — onPressed 是一個空 callback,裡面只有一行 // 導航到進度頁面 註解。而 ViewModel 層的 startExport() 早已具備 idle / inProgress / completed / failed 的完整狀態轉換邏輯,頁面從未呼叫它。
| 回饋層 | 修復前 | 修復後(W1-086) |
|---|---|---|
| 點擊確認 | 無(空 callback) | onPressed 接線 viewModel.startExport |
| 等待指示 | 無 | inProgress 時按鈕禁用 + CircularProgressIndicator |
| 結果通知 | 無 | ref.listen 監聽狀態:成功彈對話框含檔案路徑;失敗彈對話框含原因與重試按鈕 |
修復只改一個檔案(匯出設定頁),ViewModel 與狀態定義一行未動。
判讀
三層回饋全缺時,「正確的後端」對使用者不存在。回饋是使用者感知系統狀態的唯一通道。點擊確認回答「系統收到了嗎」、等待指示回答「還在跑嗎」、結果通知回答「成功了嗎、檔案在哪」。三層全缺時,使用者只能靠猜 — 而猜的預設答案是「壞掉了」。
缺口在接線、設計本身完備。狀態機四個狀態齊全、
retryExport()都寫好了。缺口是 UI 層留了一行 TODO 式註解就交付。domain 層完備反而讓缺口更隱蔽:單元測試全綠(ViewModel 邏輯正確),widget 測試只斷言畫面渲染,沒有斷言「按下後狀態轉換」,測試體系抓不到這種缺口。onPressed: () {}是可機械掃描的訊號。空 callback、只含註解的 callback,跟寫了函式沒有呼叫者一樣,是「UI 死路」的字面特徵,grep 就能找到,不需要等實機測試。
策略
三層回饋條款化為驗收標準:每顆觸發非同步操作的按鈕,驗收清單固定三問 — 點擊有確認?等待有指示?結果有通知?本案修復同時把這三層寫進規格(FR-8 補「按鈕層級三層回饋」條款,引用 100ms/400ms/1s 時間門檻),讓後續頁面在設計階段就對照。
接線完整性掃描:交付前 grep
onPressed: () {}、onPressed: null(非刻意禁用場景)與只含註解的 callback。UI 接線遺漏跟死程式碼一樣可機械偵測。widget 測試必含行為斷言:每顆按鈕至少一個「tap 後斷言狀態轉換或副作用」的測試,不能只有「按鈕存在且可見」的渲染斷言。本案的 RED 測試(按下確認匯出後狀態非 idle)正是修復前缺失的那一個。
下一步路由
- 三層回饋模型的完整定義 → 互動回饋三層模型
- 非同步按鈕的生命週期設計 → 按鈕狀態設計
- 等待指示的時間門檻 → Doherty Threshold
- 類似案例(回饋誠實但誤導)→ U.C7 商品條碼的誤導性查無結果
- 未接線的另一種形態(刻意佔位)→ U.C20 管理模式操作全是佔位
#ux-design #case-study #interaction-feedback #button-states #flutter #mobile