Hi~ 我是 Eric!
Day 1 我先拿到 Flutter Learning Lab 的專案地圖。Day 2 我開始看懂 Widget tree,知道 Flutter UI 不是一堆神祕括號,而是一棵用 Widget 描述出來的畫面樹。
今天進入 Day 3。
這一天我不想只學「怎麼寫一個表單」。表單本身很重要,但如果只看 TextFormField 和 validator,很容易又回到單點學習。
Day 3 真正要理解的是:
一個單一畫面,如何被接回整個 Flutter app 的骨架。
也就是 route 怎麼帶我進來、theme 怎麼套到畫面、Form 怎麼管理欄位驗證、ViewModel 怎麼管理送出狀態。
這一天我想把幾個原本分開的東西接起來:
App Shell
-> Router
-> Theme
-> ProfileFormView
-> Form / TextFormField / validator
-> ViewModel submit state
Day 3 的目標:不要把所有東西塞進 main.dart
今天對照的專案內容主要是:
lib/main.dartlib/app.dartlib/core/router.dartlib/core/theme.dartdocs/lessons/FORM_VALIDATION_GUIDE.mddocs/lessons/ROUTING_NAVIGATION_GUIDE.mddocs/features/profile_form.mdlib/features/profile_form/
Day 3 的目標是:
- 理解
main.dart、app.dart、router.dart的分工 - 知道
go_router如何把/profile-form接到ProfileFormView - 看懂
ThemeData如何讓表單欄位吃到全域樣式 - 理解
Form、TextFormField、validator的合作方式 - 看懂 submit loading / success / error 為什麼要交給 ViewModel
以前我很容易把 Flutter app 想成:「反正從 main.dart 開始,那就所有東西都往裡面塞。」
但這個專案刻意拆成幾個層次:
main.dart 啟動 app
app.dart 組裝 app shell
core/router.dart 定義頁面入口
core/theme.dart 定義全域視覺規則
features/ 放實際功能
這種拆法不是為了看起來高級,而是為了讓每個檔案只回答一種問題。
App Shell:整個 App 的外殼
先回到 lib/main.dart:
void main() {
runApp(const ProviderScope(child: MyApp()));
}
Day 1 我已經知道,main.dart 只負責啟動 app。
真正把 app 組起來的是 lib/app.dart:
return MaterialApp.router(
title: 'Flutter Learning',
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: preferences.themeMode.toThemeMode(),
routerConfig: appRouter,
);
這裡我第一次比較清楚地感覺到 App Shell 的概念。
MaterialApp.router 就像整個 app 的外殼,它決定幾件全域事情:
- app 的 title
- 亮色主題
- 深色主題
- 目前使用哪種 theme mode
- 使用哪一套路由表
換句話說,ProfileFormView 不是一個孤立畫面。
它會被包在這個 App Shell 裡,所以它會吃到全域 theme,也會透過 appRouter 被打開。
這就是 Day 3 的第一個重點:畫面不是漂浮在空中的,它一定被某個 app shell 承載。
Router:Profile Form 是怎麼被帶進畫面的?
接著看 lib/core/router.dart。
Profile Form 對應的 route 是:
GoRoute(
path: '/profile-form',
builder: (context, state) => const ProfileFormView(),
)
這段的白話是:
當使用者進到 /profile-form,
請建立 ProfileFormView 當作這個 route 的畫面。
而首頁的學習項目會透過 context.push(item.path) 進到這個 route。
完整流程可以寫成:
HomePage
-> _LearningItem('Form 表單驗證', '/profile-form')
-> context.push('/profile-form')
-> appRouter
-> ProfileFormView
這裡我開始理解 go_router 的價值。
如果每個按鈕都自己寫一段 Navigator.push(),小專案還可以忍,畫面一多就會很難追。
但集中在 core/router.dart 後,我可以從一張路由表知道這個 app 有哪些入口。
Day 3 我問 Codex 的第一個問題就是:
請只看 lib/core/router.dart 和 lib/views/home_page.dart。
幫我追出從首頁點擊「Form 表單驗證」到 ProfileFormView 的完整流程。
不要先解釋其他 route。
這種問法很好用,因為它讓 AI 不會發散到整個導航系統,而是貼著我今天要理解的路徑走。
Theme:表單樣式為什麼不用每個欄位重寫?
接著我打開 lib/core/theme.dart。
這個檔案定義了 AppTheme.lightTheme 和 AppTheme.darkTheme。
其中亮色主題有一段:
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
fillColor: Colors.grey[100],
),
這段讓我理解 Theme 的價值。
Profile Form 裡的 TextFormField 沒有每一個都重複寫 border、filled、fillColor。它們可以吃到全域的 inputDecorationTheme。
也就是說:
core/theme.dart
-> AppTheme.lightTheme
-> MaterialApp.router(theme: ...)
-> ProfileFormView
-> TextFormField
這件事聽起來很小,但對真實專案很重要。
如果每個欄位都各自設定樣式,之後要調整整個 app 的輸入框風格,就會變成到處找 InputDecoration。
把共用視覺規則放在 Theme 裡,畫面就能專心描述自己的結構與行為。
我也問 Codex:
請比較「每個 TextFormField 都自己寫樣式」和「集中在 ThemeData」的差別。
用可維護性和一致性的角度說明。
這個回答讓我開始把 Theme 看成架構的一部分,而不是單純調顏色的地方。
Form:多個欄位的驗證容器
終於進到今天的主角:ProfileFormView。
它使用的是 ConsumerStatefulWidget:
class ProfileFormView extends ConsumerStatefulWidget {
const ProfileFormView({super.key});
@override
ConsumerState<ProfileFormView> createState() => _ProfileFormViewState();
}
這裡為什麼不是 StatelessWidget?
因為這個頁面需要保存幾個短期 UI 物件:
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _bioController = TextEditingController();
這些 controller 和 form key 都跟畫面生命週期有關,所以放在 state 裡很合理,並且要在 dispose() 裡釋放。
表單本體是:
Form(
key: _formKey,
child: ListView(
children: [
TextFormField(...),
TextFormField(...),
TextFormField(...),
],
),
)
我用白話記:
Form 是驗證容器,TextFormField 是欄位,validator 是每個欄位的守門員。
按下送出時,會先跑:
if (formState == null || !formState.validate()) {
return;
}
validate() 會觸發每個欄位的 validator。只要其中一個欄位回傳錯誤字串,表單就不會送出。
Validator:不要把規則全部塞在 Widget 裡
這個專案把驗證規則拆到:
lib/features/profile_form/presentation/profile_form_validators.dart
裡面像這樣:
static String? email(String? value) {
final text = value?.trim() ?? '';
if (text.isEmpty) {
return '請輸入 Email';
}
if (!text.contains('@') || !text.contains('.')) {
return 'Email 格式不正確';
}
return null;
}
這段有一個很重要的規則:
回傳 null:代表驗證通過
回傳字串:代表驗證失敗,字串會顯示成錯誤訊息
把 validator 拆出來的好處是,它可以被 unit test 保護。
如果 validator 全部寫在 Widget 裡,測試就會比較依賴畫面操作;拆成 helper 後,我可以直接測:
- 空姓名要顯示錯誤
- Email 格式錯誤要顯示錯誤
- 密碼少於 8 碼要顯示錯誤
我問 Codex:
ProfileFormValidators 為什麼放在 presentation?
它不是 domain rule 嗎?
請用這個專案的教學定位解釋。
這個問題幫我釐清一件事:不是所有驗證都一定是 domain rule。
像這裡的欄位必填、Email 基本格式、密碼長度,比較接近表單 UI 的輸入規則,所以放在 presentation helper 很合理。等未來有真正業務規則,例如帳號是否已存在,那可能就會牽涉 repository 或後端回傳的 server-side error。
Submit state:送出中、成功、失敗要有狀態
表單最容易被初學者忽略的地方,是送出狀態。
很多範例只示範:
按下送出 -> 印出資料
但真實 app 不是這樣。
送出通常是非同步流程,至少會有:
idle
-> submitting
-> success / error
這個專案用 ProfileFormSubmissionState 表示:
class ProfileFormSubmissionState {
const ProfileFormSubmissionState({
this.isSubmitting = false,
this.isSuccess = false,
this.errorMessage,
});
}
而 ProfileFormViewModel 負責送出流程:
Future<void> submit(ProfileFormData data) async {
state = const ProfileFormSubmissionState(isSubmitting: true);
try {
await ref.read(profileFormRepositoryProvider).submitProfile(data);
state = const ProfileFormSubmissionState(isSuccess: true);
} on Object catch (error) {
state = ProfileFormSubmissionState(
errorMessage: '送出失敗:$error',
);
}
}
這裡我看到 ViewModel 的角色:
- View 負責顯示欄位和接收使用者操作
- Form / validator 負責欄位驗證
- ViewModel 負責送出狀態
- Repository 負責送出資料
這樣一拆,表單畫面就不會變成一個什麼都做的巨大 Widget。
錯誤訊息要回到畫面,不要只印在 console
ProfileFormView 裡有一段:
if (submissionState.errorMessage != null) ...[
const SizedBox(height: 16),
_ErrorDisplay(message: submissionState.errorMessage!),
],
這讓我注意到「錯誤分兩種」。
第一種是欄位錯誤,例如 Email 格式錯誤。這種錯誤由 validator 顯示在欄位附近。
第二種是送出錯誤,例如 repository 失敗。這種錯誤不是某一個欄位的問題,所以應該顯示在頁面上的 error display。
我以前很常把錯誤印在 console,然後覺得「有看到錯誤就好」。
但對使用者來說,console 根本不存在。
所以 Day 3 我給自己一個簡單規則:
開發者要看的錯誤,可以進 log。
使用者要知道的錯誤,要回到 UI。
這也是測試要驗證 error display 的原因。
我怎麼用 Codex 追 Day 3?
Day 3 我跟 Codex 的對話方式,比 Day 2 更像在追資料流。
我先問 route:
請從 HomePage 的 Form 表單驗證項目開始,
追到 router.dart,再追到 ProfileFormView。
用箭頭列出完整流程。
接著問 app shell:
ProfileFormView 為什麼可以吃到 core/theme.dart 裡的 inputDecorationTheme?
請從 MaterialApp.router 的 theme 開始解釋。
再來才問表單:
請把 ProfileFormView 的責任拆成:
UI 結構、欄位狀態、欄位驗證、submit 狀態、送出資料。
哪些責任留在 View?哪些交給 ViewModel?哪些交給 Repository?
最後我會問一個很實用的問題:
如果我要新增手機欄位,請告訴我需要同步修改哪些檔案。
不要直接幫我改,先列出修改順序與測試位置。
這種問法可以避免我只會複製貼上欄位。
它會逼我看懂一個 feature 的完整修改面:
profile_form_view.dartprofile_form_validators.dartprofile_form_data.dart- validator test
- widget test
這也是我覺得 AI 當導師很有用的地方。它不只是回答「怎麼寫」,而是可以提醒我「改這裡會牽動哪裡」。
Day 3 常見卡點:我用問答拆 Form 和 App Shell
Day 3 的概念容易混在一起:router、theme、form、validator、ViewModel 全部都出現。
所以我把問題拆成幾個小問答。
Q1:ProfileFormView 為什麼不是孤立畫面?
請從 app.dart 開始說明,
ProfileFormView 是怎麼被 MaterialApp.router、appRouter 和 theme 包起來的?
Codex 幫我整理成:
MyApp
-> MaterialApp.router
-> appRouter
-> /profile-form
-> ProfileFormView
這讓我知道表單不是單獨存在,它是被 App Shell 承載的頁面。
Q2:Form 和 TextFormField 差在哪?
Form、TextFormField、validator 三者各自負責什麼?
請不要只給定義,請用 ProfileFormView 解釋。
我得到的理解是:
Form 是驗證容器。
TextFormField 是單一欄位。
validator 是欄位守門員。
送出時呼叫 FormState.validate(),才會一次觸發所有欄位 validator。
Q3:validator 為什麼要拆出去?
ProfileFormValidators 為什麼不直接寫在 TextFormField 裡?
拆出去的實際好處是什麼?
Codex 把答案拉到測試:
拆出去後,validator 可以用 unit test 測。
Widget 就能專心描述 UI。
這讓我開始理解,乾淨的架構不是為了漂亮,而是為了可測試。
Q4:送出錯誤為什麼不能只印 console?
表單送出失敗時,為什麼 errorMessage 要回到 UI?
console log 不夠嗎?
這個回答很直接:
console 是開發者看的。
error display 是使用者看的。
所以 Day 3 我記住:欄位錯誤交給 validator,送出錯誤要回到畫面上的 error display。
今日最小練習:新增一個手機欄位的修改計畫
Day 3 的最小練習,我先不急著真的改完整功能。
我先做一份修改計畫:
- 在
ProfileFormView新增手機TextEditingController - 在
dispose()釋放 controller - 新增一個
TextFormField,label 是「手機」 - 在
ProfileFormValidators新增phone - 決定
ProfileFormData是否需要新增 phone 欄位 - 補 validator test
- 補 widget test,確認空手機或格式錯誤時會顯示錯誤
這個練習的重點不是手機欄位本身。
真正的重點是:我開始知道新增一個表單欄位,不只是多貼一個 TextFormField。
它會牽涉資料、驗證、送出狀態與測試。
Day 3 結論:畫面要接回骨架,互動要接回狀態
Day 3 我學到的不是「Flutter 表單語法」而已。
我真正補起來的是 App Shell 的觀念。
我現在可以說出:
main.dart負責啟動 appapp.dart用MaterialApp.router組裝 app shellcore/router.dart集中管理 route/profile-form會建立ProfileFormViewcore/theme.dart讓表單欄位吃到全域輸入框樣式Form是欄位驗證容器TextFormField適合需要驗證的輸入欄位- validator 回傳
null代表通過,回傳字串代表錯誤 ProfileFormViewModel管理送出中、成功、失敗- 錯誤訊息要回到 UI,而不是只印在 console
Day 1 我知道專案地圖,Day 2 我看懂 Widget tree。
Day 3 則讓我知道:一個畫面不能只看自己,它一定要接回 app 的 route、theme、state 和測試。
下一篇,我會進入更接近真實 app 的資料流:Posts API、搜尋、篩選、分頁,以及 ViewModel、Repository、Service 之間的分工。