Hi~ 我是 Eric!

Day 4 我從 Posts 列表一路追到 API,開始理解 View、ViewModel、Repository、Service 之間的資料流。

今天進入 Day 5。

這一天的主題是 Settings。

表面上它只是設定頁,讓使用者選擇跟隨系統、亮色模式或深色模式。

但我覺得 Day 5 真正有趣的地方不是「切換深色模式」。

真正有趣的是:

一個設定頁的選擇,如何被保存起來,並影響整個 MaterialApp

這跟 Day 4 的 Posts 不一樣。Posts 是資料從 API 進到畫面;Settings 是使用者的選擇從畫面回到本地儲存,再回頭影響 App Shell。


Day 5 的目標:讓使用者選擇活過 App 重啟

今天對照的專案內容主要是:

  • docs/features/settings.md
  • docs/lessons/LOCAL_STORAGE_GUIDE.md
  • lib/features/settings/domain/user_preferences.dart
  • lib/features/settings/data/settings_repository.dart
  • lib/features/settings/presentation/settings_view_model.dart
  • lib/features/settings/presentation/settings_view.dart
  • lib/services/storage_service.dart
  • lib/app.dart

Day 5 我想看懂這條資料流:

SettingsView
  -> SettingsViewModel.setThemeMode
  -> SettingsRepository.saveThemeMode
  -> StorageService.saveString
  -> SharedPreferencesAsync
  -> MyApp rebuild
  -> MaterialApp.router(themeMode: ...)

這條路徑最重要的地方是:設定不是只留在設定頁。

使用者選完主題後,App Shell 也要知道,下一次 App 啟動時也要讀得回來。

這就是本地偏好設定的價值。


Settings feature 的檔案地圖

先看 feature-first 結構:

lib/features/settings/
  domain/
    user_preferences.dart
  data/
    settings_repository.dart
  presentation/
    settings_view_model.dart
    settings_view.dart

這個 feature 比 Posts 小很多,但結構一樣完整。

層級負責內容
domain/定義使用者偏好與主題模式
data/負責把偏好存進本地儲存
presentation/顯示設定頁,處理使用者操作與 UI state

Day 4 的 Posts 是網路資料範例。

Day 5 的 Settings 則是本地資料範例。

這讓我看到 feature-first 架構的一個優點:資料來源可以不同,但閱讀方式可以一致。


Domain:不要讓 UI 直接保存字串

Settings 的 domain 檔案是:

lib/features/settings/domain/user_preferences.dart

裡面定義了 AppThemeMode

enum AppThemeMode {
  system,
  light,
  dark;
}

這裡有一個小但重要的設計:UI 不直接保存 'dark''light' 這種字串。

UI 使用有型別的 enum。

真正要存進 SharedPreferences 時,才轉成穩定字串:

String get storageValue => name;

而要交給 Flutter 的 MaterialApp 時,再轉成 Flutter 內建的 ThemeMode

ThemeMode toThemeMode() {
  return switch (this) {
    AppThemeMode.system => ThemeMode.system,
    AppThemeMode.light => ThemeMode.light,
    AppThemeMode.dark => ThemeMode.dark,
  };
}

這裡我學到一件事:

本地儲存需要字串,但 app 內部不應該到處傳字串。

字串很容易打錯,也很難被編譯器保護。用 enum 表達固定選項,讀起來也比較清楚。


StorageService:共用的本地儲存工具

接著看 lib/services/storage_service.dart

這個檔案包了一層 SharedPreferencesAsync

class StorageService {
  StorageService({SharedPreferencesAsync? preferences})
      : _preferences = preferences ?? SharedPreferencesAsync();

  Future<void> saveString(String key, String value) async {
    await _preferences.setString(key, value);
  }

  Future<String?> getString(String key) async {
    return _preferences.getString(key);
  }
}

它的定位很像 Day 4 的 ApiClient

ApiClient 是共用網路基礎設施。

StorageService 是共用本地儲存基礎設施。

它不應該知道 Settings feature 的細節,也不應該知道什麼是 ThemeMode

它只提供簡單的 key-value 讀寫能力。

這樣未來如果其他 feature 也需要存簡單設定,例如排序方式、教學提示是否顯示,就可以共用這一層。


Repository:把字串轉回有型別的偏好設定

SettingsRepository 是 Settings feature 的資料入口。

abstract class SettingsRepository {
  Future<UserPreferences> loadPreferences();
  Future<void> saveThemeMode(AppThemeMode themeMode);
}

實作是 LocalSettingsRepository

它會用固定 key 存主題模式:

const _themeModeKey = 'settings.theme_mode';

載入時:

final storedThemeMode = await _storageService.getString(_themeModeKey);

return UserPreferences(
  themeMode: AppThemeMode.fromStorageValue(storedThemeMode),
);

保存時:

return _storageService.saveString(_themeModeKey, themeMode.storageValue);

Repository 的價值在這裡很清楚:

  • ViewModel 不知道 SharedPreferences API
  • ViewModel 不知道 storage key 叫什麼
  • UI 不需要處理字串和 enum 的互轉
  • 測試可以用 fake repository 或 fake storage service

換句話說,Repository 把「本地儲存細節」擋在 feature 的 data layer。


ViewModel:設定頁和 App Shell 共用同一份狀態

SettingsViewModel 使用 AsyncNotifier<UserPreferences>

初始載入:

Future<UserPreferences> build() {
  return ref.read(settingsRepositoryProvider).loadPreferences();
}

切換主題:

Future<void> setThemeMode(AppThemeMode themeMode) async {
  final currentPreferences = state.valueOrNull ?? const UserPreferences();

  await ref.read(settingsRepositoryProvider).saveThemeMode(themeMode);

  state = AsyncData(
    currentPreferences.copyWith(themeMode: themeMode),
  );
}

這段我覺得很有意思。

它不是只「存資料」。

它做了兩件事:

  1. 先保存到 repository,讓 App 重啟後讀得回來
  2. 再更新記憶體中的 state,讓目前畫面立即反應

所以 SettingsViewModel 同時連接了兩個世界:

本地儲存中的偏好
目前 app 執行中的 UI state

這就是為什麼 SettingsView 和 MyApp 可以共用同一份 state。


SettingsView:RadioListTile 只是表面

設定頁本身使用 RadioListTile<AppThemeMode>

for (final mode in AppThemeMode.values)
  RadioListTile<AppThemeMode>(
    title: Text(mode.label),
    value: mode,
    groupValue: preferences.themeMode,
    onChanged: (value) {
      if (value == null) {
        return;
      }

      ref
          .read(settingsViewModelProvider.notifier)
          .setThemeMode(value);
    },
  )

表面上只是三個 radio:

  • 跟隨系統
  • 亮色模式
  • 深色模式

但它做的事情其實是:

使用者選擇 AppThemeMode
  -> 呼叫 SettingsViewModel.setThemeMode
  -> 保存偏好
  -> 更新 provider state
  -> 讓所有 watch 這個 provider 的地方重建

這裡的關鍵是最後一句。

不是只有 SettingsView 會 watch settingsViewModelProvider

MyApp 也會。


MyApp:設定頁如何影響整個 MaterialApp?

回到 lib/app.dart

MyAppConsumerWidget

final preferences = ref.watch(settingsViewModelProvider).valueOrNull ??
    const UserPreferences();

然後把使用者偏好轉成 MaterialApp.routerthemeMode

themeMode: preferences.themeMode.toThemeMode(),

這就是 Day 5 的核心。

設定頁不是直接去改 MaterialApp

設定頁只是更新 settingsViewModelProvider

MyApp 監聽同一個 provider,所以當設定改變時,MyApp 會重建,MaterialApp.router 會拿到新的 themeMode

完整流程是:

SettingsView
  -> setThemeMode(AppThemeMode.dark)
  -> SettingsViewModel
  -> SettingsRepository
  -> StorageService
  -> SharedPreferencesAsync
  -> settingsViewModelProvider state updated
  -> MyApp rebuild
  -> MaterialApp.router(themeMode: ThemeMode.dark)

這一段讓我真正理解到:Riverpod 不只是讓某個頁面拿資料,它也可以讓 app shell 監聽全域狀態。


SharedPreferences 適合存什麼?

LOCAL_STORAGE_GUIDE.md 裡有一個重要提醒:SharedPreferences 適合簡單 key-value。

適合:

  • 主題偏好
  • 是否看過引導頁
  • 簡單搜尋紀錄
  • 非敏感的排序或顯示設定

不適合:

  • access token
  • 密碼
  • 大量結構化資料
  • 需要複雜查詢的資料

這點很重要。

因為「能存」不代表「應該存」。

像 access token 這種敏感資料,應該考慮 flutter_secure_storage。大量資料或需要搜尋排序的資料,則可能需要本地資料庫。

Day 5 我先記一個簡單原則:

使用者偏好可以用 SharedPreferences。
敏感資料不要用一般 SharedPreferences。
大量結構化資料不要硬塞 key-value。

我怎麼用 Codex 追 Day 5?

Day 5 我跟 Codex 的對話重點,是「設定如何跨層流動」。

我先問:

請從 SettingsView 的 RadioListTile 開始,
一路追到 MaterialApp.router 的 themeMode。
用箭頭列出完整資料流。

接著問分層:

SettingsRepository 為什麼需要存在?
如果 SettingsViewModel 直接呼叫 SharedPreferences,會有什麼缺點?

再問型別設計:

為什麼 UI 使用 AppThemeMode enum,
但 SharedPreferences 裡存的是字串?
AppThemeMode.toThemeMode() 的責任是什麼?

最後問修改面:

如果我要新增「是否顯示教學提示」的 boolean 偏好,
請先列出會牽動哪些檔案和測試,不要直接幫我改。

這種問法會讓 Codex 幫我做影響分析:

  • UserPreferences
  • SettingsRepository
  • StorageService
  • SettingsViewModel
  • SettingsView
  • repository test
  • ViewModel test
  • widget test

對我來說,這比直接叫 AI 生出一個 switch 更有價值。

因為我學到的是:新增一個設定,不只是畫面上多一個開關,而是整條偏好資料流都要接起來。


Day 5 常見卡點:設定頁為什麼會影響整個 App?

Settings feature 看起來很小,但它的資料流其實跨了好幾層。

我用幾個問答把它拆開。

Q1:為什麼 UI 不直接存 'dark' 字串?

SettingsView 可不可以直接把 'dark' 存進 SharedPreferences?
為什麼還要 AppThemeMode enum?

Codex 的回答是:字串適合儲存,不適合在 app 內部到處傳。

我後來這樣記:

UI 用 enum,儲存用字串。
Repository 負責轉換。

這樣編譯器能保護固定選項,也比較不怕打錯字。

Q2:SettingsRepository 為什麼需要存在?

SettingsViewModel 直接呼叫 StorageService 不行嗎?
為什麼中間要有 SettingsRepository?

Codex 幫我拆出責任:

StorageService 只懂 key-value。
SettingsRepository 懂 settings 的 storage key 和 domain model 轉換。
ViewModel 只管 UI state 和使用者事件。

這讓我理解 repository 不是只有 API feature 才需要,本地儲存也需要隔離細節。

Q3:為什麼 MyApp 要 watch settings provider?

SettingsView 改主題就好了,
為什麼 MyApp 也要 ref.watch(settingsViewModelProvider)?

這個回答是 Day 5 的核心:

因為 themeMode 是 MaterialApp 的設定。
SettingsView 只是改變偏好。
MyApp 監聽同一份偏好,才能讓整個 App 套用新主題。

所以設定頁不是直接改全站 UI,而是更新 provider state,讓 App Shell 自己重建。

Q4:SharedPreferences 可以存所有東西嗎?

既然 SharedPreferences 很方便,
可不可以順便拿來存 token、使用者資料、列表 cache?

Codex 的回答很保守,也很實用:

非敏感、簡單 key-value 可以。
敏感資料不要。
大量結構化資料不要。

這讓我把 SharedPreferences 定位成「偏好設定儲存」,不是萬用資料庫。


今日最小練習:新增偏好設定前的修改計畫

Day 5 的最小練習,我先做「規劃」而不是立刻改 code。

假設我要新增一個偏好:

是否顯示教學提示

我會先寫出修改順序:

  1. UserPreferences 新增 showLearningTips
  2. copyWith() 補上新欄位
  3. SettingsRepository 新增讀取與保存方法,或調整 loadPreferences()
  4. LocalSettingsRepository 新增 storage key
  5. SettingsViewModel 新增切換方法
  6. SettingsViewSwitchListTile 顯示設定
  7. 補 repository test
  8. 補 ViewModel test
  9. 補 widget test

這個練習的重點不是功能多難。

重點是我開始知道:偏好設定是一條完整資料流,不是一個孤立 checkbox。


Day 5 結論:設定頁不是小功能,它會碰到整個 App

Day 5 我學到的是,Settings feature 雖然小,但很能展示 Flutter app 的全域狀態流。

我現在可以說出:

  • SharedPreferencesAsync 適合保存非敏感 key-value 偏好
  • StorageService 是共用本地儲存基礎設施
  • SettingsRepository 負責隔離 storage key 和字串轉換
  • AppThemeMode 是 app 內部使用的有型別主題偏好
  • AppThemeMode.toThemeMode() 會轉成 Flutter 內建 ThemeMode
  • SettingsViewModel 先保存偏好,再更新 provider state
  • SettingsView 只負責顯示選項和轉交使用者操作
  • MyApp 監聽同一個 provider,讓 MaterialApp.router 即時套用新的 themeMode
  • 新增偏好設定時,要同步考慮 domain、data、presentation 與 tests

這一天讓我更確定一件事:

Flutter 的學習不能只看畫面。

一個看似簡單的設定頁,背後其實串起了本地儲存、型別設計、狀態管理、App Shell 和測試。

下一篇,我會進入測試策略,看看這個學習專案如何用 repository test、ViewModel test、widget test,保護每個 feature 的行為。