Flutterでgo_routerの認証ガードを実装する(redirect最小構成) の次に整理しておきたいのが、設定値の保存先です。API 接続先、表示件数、テーマ、トークンのような値は性質が違うため、どこへ保存するかを先に分けておかないと実装中に迷いやすくなります。この記事では shared_preferences と flutter_secure_storage を並べて使い、端末設定と利用者設定を保存する最小の設定画面を lib/main.dart 1 ファイルで作ります。
1. ゴールと非対象
対象読者
- Flutter の環境構築、基本 UI、ルーティング、REST API 通信の入口までは進んでいる人
SharedPreferencesとsecure storageのどちらへ保存すべきかまだ曖昧な人- ログイン状態保持へ進む前に、設定値の置き場を単独で理解しておきたい人
この記事で到達する状態
SharedPreferencesに向く値とsecure storageに向く値を説明できる- 起動時に保存済み設定を復元できる
- 設定画面の最小例を
lib/main.dart1 ファイルで動かせる - 初期値の扱いと保存後の UI 反映を説明できる
secure storage側だけを消す操作の意味を説明できる
非対象
- JWT の有効期限判定やリフレッシュ
- 認証 API 呼び出し
- Riverpod や Bloc を使った本格的な永続化設計
- SQLite やファイル保存との比較
- Web 固有の保存戦略
今回は「どこに保存するか」の入口に絞ります。トークン自体は例として保存しますが、ログイン処理そのものは後続記事へ回します。
2. 先に保存先の基準を決める
まずは、どの値をどこへ置くかを整理した表を見ます。
| 項目 | 保存先 | 理由 |
|---|---|---|
| API 接続先 | SharedPreferences | 端末全体の挙動に関わる設定で、秘密値ではないため |
| 一覧の表示件数 | SharedPreferences | 利用者が切り替える UI 設定で、平文でも困らないため |
| ダークモード | SharedPreferences | アプリの見た目を決める端末設定として扱いやすいため |
| アクセストークン | flutter_secure_storage | 認証に使う秘密値で、通常の設定値と分けたいから |
| 作業者 PIN | flutter_secure_storage | 利用者固有で外に出したくない値だから |
見る基準は 2 つです。
- 端末全体の挙動を決める、秘密でない値か
- 利用者固有で、なるべく安全に扱いたい値か
今回の流れは次の通りです。
flowchart TD
A[アプリ起動] --> B[SharedPreferences から端末設定を読む]
A --> C[secure storage から利用者設定を読む]
B --> D[初期値が無ければ既定値を使う]
C --> D
D --> E[設定画面へ反映]
E --> F[利用者が保存を押す]
F --> G[端末設定を SharedPreferences へ保存]
F --> H[秘密値を secure storage へ保存]
G --> I[テーマと要約カードを更新]
H --> I
ここで大事なのは、初期値まで保存先へ押し込まないことです。保存されていないときに何を表示するかは、まずコード上で決めておいたほうが読みやすくなります。
3. プロジェクトを作成し、パッケージを追加する
3-1. Flutter の環境構築がまだなら先に済ませる
環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。
3-2. Flutter プロジェクトを作成する
次のコマンドでプロジェクトを作成します。
flutter create my_settings_app
cd my_settings_app
3-3. エミュレーターを起動する
利用可能なエミュレーター一覧を確認します。
flutter emulators
表示された ID を指定して起動します。
flutter emulators --launch <emulator_id>
3-4. パッケージを追加する
プロジェクト直下で次のコマンドを実行します。
flutter pub add shared_preferences
flutter pub add flutter_secure_storage
役割は次の通りです。
shared_preferences: API 接続先、表示件数、テーマなど、秘密ではない設定値を保持するflutter_secure_storage: アクセストークンや PIN のように、通常設定と分けたい値を保持する
flutter_secure_storage は OS の安全な保存機構を使います。細かいプラットフォーム差異はありますが、最初の判断基準としては「秘密値は通常設定と分ける」で十分です。
4. lib/main.dart に設定画面を作る
lib/main.dart は次の内容で作成します。
このファイルは、端末設定と利用者設定を別の保存先へ分けて扱う最小サンプルです。注目点は、Repository を 2 つに分けたうえで、起動時の復元、保存、利用者設定だけのクリアまでを 1 画面で確認できることです。
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
ThemeMode _themeMode = ThemeMode.light;
void _handleThemeChanged(bool isDarkMode) {
setState(() {
_themeMode = isDarkMode ? ThemeMode.dark : ThemeMode.light;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Settings storage demo',
themeMode: _themeMode,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.teal,
brightness: Brightness.dark,
),
),
home: SettingsPage(onThemeChanged: _handleThemeChanged),
);
}
}
class DeviceSettings {
const DeviceSettings({
required this.apiBaseUrl,
required this.pageSize,
required this.isDarkMode,
});
final String apiBaseUrl;
final int pageSize;
final bool isDarkMode;
static const DeviceSettings defaults = DeviceSettings(
apiBaseUrl: 'http://10.0.2.2:3000',
pageSize: 20,
isDarkMode: false,
);
}
class UserSecrets {
const UserSecrets({
required this.accessToken,
required this.workerPin,
});
final String accessToken;
final String workerPin;
static const UserSecrets empty = UserSecrets(
accessToken: '',
workerPin: '',
);
}
class SavedSnapshot {
const SavedSnapshot({
required this.deviceSettings,
required this.userSecrets,
});
final DeviceSettings deviceSettings;
final UserSecrets userSecrets;
}
class DeviceSettingsRepository {
static const String _apiBaseUrlKey = 'settings.apiBaseUrl';
static const String _pageSizeKey = 'settings.pageSize';
static const String _darkModeKey = 'settings.isDarkMode';
Future<DeviceSettings> load() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
return DeviceSettings(
apiBaseUrl:
prefs.getString(_apiBaseUrlKey) ?? DeviceSettings.defaults.apiBaseUrl,
pageSize: prefs.getInt(_pageSizeKey) ?? DeviceSettings.defaults.pageSize,
isDarkMode:
prefs.getBool(_darkModeKey) ?? DeviceSettings.defaults.isDarkMode,
);
}
Future<void> save(DeviceSettings settings) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString(_apiBaseUrlKey, settings.apiBaseUrl);
await prefs.setInt(_pageSizeKey, settings.pageSize);
await prefs.setBool(_darkModeKey, settings.isDarkMode);
}
}
class UserSecretsRepository {
UserSecretsRepository({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
static const String _accessTokenKey = 'user.accessToken';
static const String _workerPinKey = 'user.workerPin';
final FlutterSecureStorage _storage;
Future<UserSecrets> load() async {
final String accessToken =
await _storage.read(key: _accessTokenKey) ?? '';
final String workerPin = await _storage.read(key: _workerPinKey) ?? '';
return UserSecrets(
accessToken: accessToken,
workerPin: workerPin,
);
}
Future<void> save(UserSecrets secrets) async {
await _storage.write(key: _accessTokenKey, value: secrets.accessToken);
await _storage.write(key: _workerPinKey, value: secrets.workerPin);
}
Future<void> clear() async {
await _storage.delete(key: _accessTokenKey);
await _storage.delete(key: _workerPinKey);
}
}
class SettingsPage extends StatefulWidget {
const SettingsPage({
super.key,
required this.onThemeChanged,
});
final ValueChanged<bool> onThemeChanged;
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
final DeviceSettingsRepository _deviceSettingsRepository =
DeviceSettingsRepository();
final UserSecretsRepository _userSecretsRepository = UserSecretsRepository();
final TextEditingController _apiBaseUrlController = TextEditingController();
final TextEditingController _pageSizeController = TextEditingController();
final TextEditingController _accessTokenController = TextEditingController();
final TextEditingController _workerPinController = TextEditingController();
bool _isDarkMode = DeviceSettings.defaults.isDarkMode;
bool _isLoading = true;
bool _isSaving = false;
SavedSnapshot? _savedSnapshot;
@override
void initState() {
super.initState();
_loadSettings();
}
@override
void dispose() {
_apiBaseUrlController.dispose();
_pageSizeController.dispose();
_accessTokenController.dispose();
_workerPinController.dispose();
super.dispose();
}
Future<void> _loadSettings() async {
final DeviceSettings deviceSettings = await _deviceSettingsRepository.load();
final UserSecrets userSecrets = await _userSecretsRepository.load();
if (!mounted) {
return;
}
_apiBaseUrlController.text = deviceSettings.apiBaseUrl;
_pageSizeController.text = deviceSettings.pageSize.toString();
_accessTokenController.text = userSecrets.accessToken;
_workerPinController.text = userSecrets.workerPin;
widget.onThemeChanged(deviceSettings.isDarkMode);
setState(() {
_isDarkMode = deviceSettings.isDarkMode;
_isLoading = false;
_savedSnapshot = SavedSnapshot(
deviceSettings: deviceSettings,
userSecrets: userSecrets,
);
});
}
Future<void> _saveSettings() async {
final int pageSize = int.tryParse(_pageSizeController.text.trim()) ??
DeviceSettings.defaults.pageSize;
final DeviceSettings deviceSettings = DeviceSettings(
apiBaseUrl: _apiBaseUrlController.text.trim().isEmpty
? DeviceSettings.defaults.apiBaseUrl
: _apiBaseUrlController.text.trim(),
pageSize: pageSize,
isDarkMode: _isDarkMode,
);
final UserSecrets userSecrets = UserSecrets(
accessToken: _accessTokenController.text.trim(),
workerPin: _workerPinController.text.trim(),
);
setState(() {
_isSaving = true;
});
await _deviceSettingsRepository.save(deviceSettings);
await _userSecretsRepository.save(userSecrets);
if (!mounted) {
return;
}
widget.onThemeChanged(deviceSettings.isDarkMode);
setState(() {
_pageSizeController.text = deviceSettings.pageSize.toString();
_savedSnapshot = SavedSnapshot(
deviceSettings: deviceSettings,
userSecrets: userSecrets,
);
_isSaving = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('設定を保存しました。')),
);
}
Future<void> _clearUserSecrets() async {
await _userSecretsRepository.clear();
if (!mounted) {
return;
}
_accessTokenController.clear();
_workerPinController.clear();
setState(() {
final DeviceSettings deviceSettings =
_savedSnapshot?.deviceSettings ?? DeviceSettings.defaults;
_savedSnapshot = SavedSnapshot(
deviceSettings: deviceSettings,
userSecrets: UserSecrets.empty,
);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('利用者設定だけを消去しました。')),
);
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final SavedSnapshot snapshot =
_savedSnapshot ??
const SavedSnapshot(
deviceSettings: DeviceSettings.defaults,
userSecrets: UserSecrets.empty,
);
return Scaffold(
appBar: AppBar(
title: const Text('端末設定と利用者設定'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'保存先の使い分けを 1 画面で確認します。',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
const Text(
'API 接続先、表示件数、テーマは SharedPreferences へ、アクセストークンと作業者 PIN は secure storage へ保存します。',
),
],
),
),
),
const SizedBox(height: 16),
_SectionCard(
title: '端末設定(SharedPreferences)',
description: '秘密ではない値を保存します。',
child: Column(
children: <Widget>[
TextField(
controller: _apiBaseUrlController,
decoration: const InputDecoration(
labelText: 'API 接続先',
hintText: 'http://10.0.2.2:3000',
),
),
const SizedBox(height: 12),
TextField(
controller: _pageSizeController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '一覧の表示件数',
),
),
const SizedBox(height: 12),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('ダークモード'),
subtitle: const Text('保存後にすぐ反映します。'),
value: _isDarkMode,
onChanged: (bool value) {
setState(() {
_isDarkMode = value;
});
},
),
],
),
),
const SizedBox(height: 16),
_SectionCard(
title: '利用者設定(secure storage)',
description: '秘密として扱いたい値を分けて保存します。',
child: Column(
children: <Widget>[
TextField(
controller: _accessTokenController,
decoration: const InputDecoration(
labelText: 'アクセストークン',
),
),
const SizedBox(height: 12),
TextField(
controller: _workerPinController,
obscureText: true,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '作業者 PIN',
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: _clearUserSecrets,
icon: const Icon(Icons.lock_reset),
label: const Text('利用者設定だけを消去'),
),
),
],
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'現在の保存内容',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Text('API 接続先: ${snapshot.deviceSettings.apiBaseUrl}'),
Text('表示件数: ${snapshot.deviceSettings.pageSize}'),
Text(
'ダークモード: ${snapshot.deviceSettings.isDarkMode ? 'ON' : 'OFF'}',
),
Text(
'アクセストークン: ${_mask(snapshot.userSecrets.accessToken)}',
),
Text(
'作業者 PIN: ${_mask(snapshot.userSecrets.workerPin)}',
),
],
),
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _isSaving ? null : _saveSettings,
icon: _isSaving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: Text(_isSaving ? '保存中...' : '設定を保存'),
),
],
),
);
}
}
class _SectionCard extends StatelessWidget {
const _SectionCard({
required this.title,
required this.description,
required this.child,
});
final String title;
final String description;
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(description),
const SizedBox(height: 16),
child,
],
),
),
);
}
}
String _mask(String value) {
if (value.isEmpty) {
return '(未保存)';
}
if (value.length <= 4) {
return List<String>.filled(value.length, '*').join();
}
final String middleMask =
List<String>.filled(value.length - 4, '*').join();
return '${value.substring(0, 2)}$middleMask${value.substring(value.length - 2)}';
}
コードのポイント
① 保存先ごとに Repository を分けて責務を固定している
class DeviceSettingsRepository {
static const String _apiBaseUrlKey = 'settings.apiBaseUrl';
static const String _pageSizeKey = 'settings.pageSize';
static const String _darkModeKey = 'settings.isDarkMode';
Future<DeviceSettings> load() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
return DeviceSettings(
apiBaseUrl:
prefs.getString(_apiBaseUrlKey) ?? DeviceSettings.defaults.apiBaseUrl,
pageSize: prefs.getInt(_pageSizeKey) ?? DeviceSettings.defaults.pageSize,
isDarkMode:
prefs.getBool(_darkModeKey) ?? DeviceSettings.defaults.isDarkMode,
);
}
}
class UserSecretsRepository {
UserSecretsRepository({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
ファイル分割はしていなくても、端末設定と利用者設定を別 Repository にしておくと保存先の責務が混ざりません。SharedPreferences 側は秘密ではない値、secure storage 側は利用者固有の秘密値という切り分けをコード上でも先に作っています。
② 起動時の復元と保存後の反映を画面側でつないでいる
@override
void initState() {
super.initState();
_loadSettings();
}
Future<void> _saveSettings() async {
final int pageSize = int.tryParse(_pageSizeController.text.trim()) ??
DeviceSettings.defaults.pageSize;
final DeviceSettings deviceSettings = DeviceSettings(
apiBaseUrl: _apiBaseUrlController.text.trim().isEmpty
? DeviceSettings.defaults.apiBaseUrl
: _apiBaseUrlController.text.trim(),
pageSize: pageSize,
isDarkMode: _isDarkMode,
);
final UserSecrets userSecrets = UserSecrets(
accessToken: _accessTokenController.text.trim(),
workerPin: _workerPinController.text.trim(),
);
await _deviceSettingsRepository.save(deviceSettings);
await _userSecretsRepository.save(userSecrets);
widget.onThemeChanged(deviceSettings.isDarkMode);
setState(() {
_savedSnapshot = SavedSnapshot(
initState() で _loadSettings() を呼ぶことで、保存値が無い初回起動でも defaults と empty を使って UI が空振りしません。保存後は widget.onThemeChanged() と _savedSnapshot の更新を同時に行うため、テーマ反映と要約カードの更新を次回起動まで待たずに確認できます。
4-3. まずは起動して確認する
ここまでできたら、次のコマンドで起動します。
flutter run
初回起動では既定値が入った状態で画面が立ち上がります。API 接続先は http://10.0.2.2:3000、表示件数は 20、ダークモードは OFF、トークンと PIN は未保存です。画面が縦に長いため、ここでは上側と下側を分けて載せます。
5. どの値をどちらへ置くかを具体例で見る
今回のサンプルで分けた理由を、もう一度具体例で見ると次の通りです。
SharedPreferences に置く値
- API 接続先
- 一覧の表示件数
- ダークモード
これらは「端末の使い方」を決める値です。ログイン状態のような秘密ではないので、通常設定として切り分けるほうが扱いやすくなります。たとえば FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理) で使ったローカル API の接続先を切り替えたい場合も、端末設定として持っておくと分かりやすくなります。
secure storage に置く値
- アクセストークン
- 作業者 PIN
これらは「利用者ごとに変わる秘密値」です。ここを通常設定と同じ場所へ置くと、設定画面の都合と認証情報の都合が混ざりやすくなります。後続のログイン状態保持記事では、このトークン保存部分を本物のログインフローへつなげるイメージです。
設定を保存すると、要約カードもすぐ更新されます。たとえば API 接続先を http://10.0.2.2:4000、表示件数を 50、ダークモードを ON、アクセストークンを tok_sample_1234abcd にして保存すると、次のように端末設定と利用者設定の反映を 1 画面で確認できます。トークンや PIN をそのまま表示しないために、サンプルでは _mask() で一部を伏せています。
6. 初期値とクリア操作をどう扱うか
初期値では、保存値が無いときに UI がどう振る舞うかを見ます。
- API 接続先が無ければ
http://10.0.2.2:3000 - 表示件数が無ければ
20 - ダークモードが無ければ OFF
- トークンと PIN が無ければ空文字
このように、初期値は「最初にどう立ち上がるか」を決める役割です。保存先に値が無いからといって、毎回 null 分岐を UI 側へ散らす必要はありません。
もう 1 つ見ておきたいのが、利用者設定だけを消したい場面です。サンプルの 利用者設定だけを消去 ボタンでは secure storage 側のキーだけ削除しています。これにより、API 接続先やダークモードはそのまま残しつつ、トークンと PIN だけを破棄できます。
これは Flutterでgo_routerの認証ガードを実装する(redirect最小構成) の次に置く記事として相性がよいポイントです。ログアウト時に消したいのは端末設定ではなく、利用者固有の秘密値だからです。実際に 利用者設定だけを消去 を押すと、端末設定は残ったまま、トークンと PIN だけが未保存表示へ戻ります。
7. まとめ
SharedPreferences と secure storage は、まず「端末全体の設定か、利用者固有の秘密値か」で分けると判断しやすくなります。端末全体の挙動を決める設定は SharedPreferences、利用者固有で秘密として扱いたい値は secure storage へ分ける。この基準を先に固めておくと、設定画面、ログイン保持、フォーム、CRUD のどこへ進んでも迷いにくくなります。
次に見返しやすい既存記事は次の 3 本です。