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.dart
  • lib/app.dart
  • lib/core/router.dart
  • lib/core/theme.dart
  • docs/lessons/FORM_VALIDATION_GUIDE.md
  • docs/lessons/ROUTING_NAVIGATION_GUIDE.md
  • docs/features/profile_form.md
  • lib/features/profile_form/

Day 3 的目標是:

  1. 理解 main.dartapp.dartrouter.dart 的分工
  2. 知道 go_router 如何把 /profile-form 接到 ProfileFormView
  3. 看懂 ThemeData 如何讓表單欄位吃到全域樣式
  4. 理解 FormTextFormFieldvalidator 的合作方式
  5. 看懂 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.lightThemeAppTheme.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.dart
  • profile_form_validators.dart
  • profile_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:FormTextFormField 差在哪?

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 的最小練習,我先不急著真的改完整功能。

我先做一份修改計畫:

  1. ProfileFormView 新增手機 TextEditingController
  2. dispose() 釋放 controller
  3. 新增一個 TextFormField,label 是「手機」
  4. ProfileFormValidators 新增 phone
  5. 決定 ProfileFormData 是否需要新增 phone 欄位
  6. 補 validator test
  7. 補 widget test,確認空手機或格式錯誤時會顯示錯誤

這個練習的重點不是手機欄位本身。

真正的重點是:我開始知道新增一個表單欄位,不只是多貼一個 TextFormField

它會牽涉資料、驗證、送出狀態與測試。


Day 3 結論:畫面要接回骨架,互動要接回狀態

Day 3 我學到的不是「Flutter 表單語法」而已。

我真正補起來的是 App Shell 的觀念。

我現在可以說出:

  • main.dart 負責啟動 app
  • app.dartMaterialApp.router 組裝 app shell
  • core/router.dart 集中管理 route
  • /profile-form 會建立 ProfileFormView
  • core/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 之間的分工。