公開日 2026-07-23

Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け)

Flutterで SharedPreferences と flutter_secure_storage を使い分け、端末設定と利用者設定の保存、初期値復元、保存後反映を最小構成で確認できるようにする。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. 先に保存先の基準を決める
  6. 3. プロジェクトを作成し、パッケージを追加する
  7. 3-1. Flutter の環境構築がまだなら先に済ませる
  8. 3-2. Flutter プロジェクトを作成する
  9. 3-3. エミュレーターを起動する
  10. 3-4. パッケージを追加する
  11. 4. lib/main.dart に設定画面を作る
  12. コードのポイント
  13. 4-3. まずは起動して確認する
  14. 5. どの値をどちらへ置くかを具体例で見る
  15. SharedPreferences に置く値
  16. secure storage に置く値
  17. 6. 初期値とクリア操作をどう扱うか
  18. 7. まとめ

Flutterでgo_routerの認証ガードを実装する(redirect最小構成) の次に整理しておきたいのが、設定値の保存先です。API 接続先、表示件数、テーマ、トークンのような値は性質が違うため、どこへ保存するかを先に分けておかないと実装中に迷いやすくなります。この記事では shared_preferencesflutter_secure_storage を並べて使い、端末設定と利用者設定を保存する最小の設定画面を lib/main.dart 1 ファイルで作ります。

1. ゴールと非対象

対象読者

  • Flutter の環境構築、基本 UI、ルーティング、REST API 通信の入口までは進んでいる人
  • SharedPreferencessecure storage のどちらへ保存すべきかまだ曖昧な人
  • ログイン状態保持へ進む前に、設定値の置き場を単独で理解しておきたい人

この記事で到達する状態

  • SharedPreferences に向く値と secure storage に向く値を説明できる
  • 起動時に保存済み設定を復元できる
  • 設定画面の最小例を lib/main.dart 1 ファイルで動かせる
  • 初期値の扱いと保存後の UI 反映を説明できる
  • secure storage 側だけを消す操作の意味を説明できる

非対象

  • JWT の有効期限判定やリフレッシュ
  • 認証 API 呼び出し
  • Riverpod や Bloc を使った本格的な永続化設計
  • SQLite やファイル保存との比較
  • Web 固有の保存戦略

今回は「どこに保存するか」の入口に絞ります。トークン自体は例として保存しますが、ログイン処理そのものは後続記事へ回します。

2. 先に保存先の基準を決める

まずは、どの値をどこへ置くかを整理した表を見ます。

項目保存先理由
API 接続先SharedPreferences端末全体の挙動に関わる設定で、秘密値ではないため
一覧の表示件数SharedPreferences利用者が切り替える UI 設定で、平文でも困らないため
ダークモードSharedPreferencesアプリの見た目を決める端末設定として扱いやすいため
アクセストークンflutter_secure_storage認証に使う秘密値で、通常の設定値と分けたいから
作業者 PINflutter_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() を呼ぶことで、保存値が無い初回起動でも defaultsempty を使って 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. まとめ

SharedPreferencessecure storage は、まず「端末全体の設定か、利用者固有の秘密値か」で分けると判断しやすくなります。端末全体の挙動を決める設定は SharedPreferences、利用者固有で秘密として扱いたい値は secure storage へ分ける。この基準を先に固めておくと、設定画面、ログイン保持、フォーム、CRUD のどこへ進んでも迷いにくくなります。

次に見返しやすい既存記事は次の 3 本です。

シリーズ 23/38

このシリーズ

Flutter導入と基礎

  1. 1. Windows 11で始めるFlutter開発環境:Android Emulatorで動かすまで
  2. 2. Flutter + FVM で開発環境のバージョンを固定する
  3. 3. Flutterで画像・SVG・アイコンを管理する(flutter_gen最小構成)
  4. 4. Flutterで最初に詰まりやすいDartの書き方:final・const・null safety・async/await を最初に整理する
  5. 5. DartのStream入門(非同期データの流れをつかむ)
  6. 6. FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)
  7. 7. FlutterでBuildContextとKeyを理解する
  8. 8. Flutterのレイアウト入門(Column / Row / Stack の使い分け)
  9. 9. Flutterのテーマ設計入門(ThemeData + Theme Extension)
  10. 10. FlutterでMediaQueryとLayoutBuilderを使って画面サイズに対応する(スマホ・タブレット両対応)
  11. 11. FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本)
  12. 12. FlutterのListViewとGridViewで一覧画面を作る(基本パターン)
  13. 13. Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本)
  14. 14. FlutterのTabBarとBottomNavigationBarで複数画面を切り替える
  15. 15. Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用)
  16. 16. Flutterのルーティング入門(Navigator と go_router の使い分け)
  17. 17. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理)
  18. 18. Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う
  19. 19. Flutterの状態管理入門(Riverpod最小構成)
  20. 20. Flutterでローディング・空状態・エラー表示を整える
  21. 21. Dart 3のsealed classとパターンマッチングで分岐を安全に書く
  22. 22. Flutterでgo_routerの認証ガードを実装する(redirect最小構成)
  23. 23. Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け) 現在の記事
  24. 24. Flutterでログイン状態を保持する(JWT + secure storage 最小構成)
  25. 25. Flutterアプリを日本語化する(l10n + arb 最小構成)
  26. 26. Flutterで業務用バーコード読み取りアプリを作る(最小構成)
  27. 27. Flutterでスキャン入力を受けて処理する
  28. 28. FlutterでGS1-128バーコードを解析する
  29. 29. Flutterで単一画面の入力フローを作る
  30. 30. Flutterで複数画像の添付UIを作る
  31. 31. Flutterでデータをファイルに書き出す
  32. 32. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth)
  33. 33. Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名)
  34. 34. Flutterの環境切替と配布前チェック(flavor / release build / 権限確認)
  35. 35. FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成)
  36. 36. FlutterのIntegration Test入門(ログインから一覧表示まで確認する)
  37. 37. Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要)
  38. 38. Sentryでクラッシュとエラーを検知する(Flutter最小構成)