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.mddocs/lessons/LOCAL_STORAGE_GUIDE.mdlib/features/settings/domain/user_preferences.dartlib/features/settings/data/settings_repository.dartlib/features/settings/presentation/settings_view_model.dartlib/features/settings/presentation/settings_view.dartlib/services/storage_service.dartlib/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),
);
}
這段我覺得很有意思。
它不是只「存資料」。
它做了兩件事:
- 先保存到 repository,讓 App 重啟後讀得回來
- 再更新記憶體中的 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。
MyApp 是 ConsumerWidget:
final preferences = ref.watch(settingsViewModelProvider).valueOrNull ??
const UserPreferences();
然後把使用者偏好轉成 MaterialApp.router 的 themeMode:
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 幫我做影響分析:
UserPreferencesSettingsRepositoryStorageServiceSettingsViewModelSettingsView- 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。
假設我要新增一個偏好:
是否顯示教學提示
我會先寫出修改順序:
- 在
UserPreferences新增showLearningTips - 在
copyWith()補上新欄位 - 在
SettingsRepository新增讀取與保存方法,或調整loadPreferences() - 在
LocalSettingsRepository新增 storage key - 在
SettingsViewModel新增切換方法 - 在
SettingsView用SwitchListTile顯示設定 - 補 repository test
- 補 ViewModel test
- 補 widget test
這個練習的重點不是功能多難。
重點是我開始知道:偏好設定是一條完整資料流,不是一個孤立 checkbox。
Day 5 結論:設定頁不是小功能,它會碰到整個 App
Day 5 我學到的是,Settings feature 雖然小,但很能展示 Flutter app 的全域狀態流。
我現在可以說出:
SharedPreferencesAsync適合保存非敏感 key-value 偏好StorageService是共用本地儲存基礎設施SettingsRepository負責隔離 storage key 和字串轉換AppThemeMode是 app 內部使用的有型別主題偏好AppThemeMode.toThemeMode()會轉成 Flutter 內建ThemeModeSettingsViewModel先保存偏好,再更新 provider stateSettingsView只負責顯示選項和轉交使用者操作MyApp監聽同一個 provider,讓MaterialApp.router即時套用新的themeMode- 新增偏好設定時,要同步考慮 domain、data、presentation 與 tests
這一天讓我更確定一件事:
Flutter 的學習不能只看畫面。
一個看似簡單的設定頁,背後其實串起了本地儲存、型別設計、狀態管理、App Shell 和測試。
下一篇,我會進入測試策略,看看這個學習專案如何用 repository test、ViewModel test、widget test,保護每個 feature 的行為。