1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

アプリ実装で学ぶ!Flutterの状態管理とMVVMプログラミング入門

1
Posted at

はじめに

この記事では,FlutterでLinuxデスクトップアプリを実装しながら,状態に応じて画面を組み立てる「宣言的UI」と,MVVMアーキテクチャの入門的な内容を紹介します.

まずはボタンを1つ表示する最小構成からはじめます.次に面積計算のサンプルでFlutterの状態管理を確認し,最後にToDoアプリを中規模アプリ向けの入り口となる構成へ発展させます.

MVVMはFlutterで利用できる一般的な設計方針の一つですが,唯一の正解ではありません.Flutterでは,BLoCやRedux,RiverpodのNotifierを使った構成など,アプリの規模やプロジェクトに応じて複数の選択肢があります.本記事では,Flutter公式のアプリアーキテクチャガイドの内容も参考にして,Viewと状態・ロジックの責務分離やテストのしやすさを意識して,MVVMアーキテクチャを採用します.

この記事で作るもの

  1. Flutterアプリの最小画面
  2. 面積計算画面
  3. ToDo画面

まずは開発環境を構築して最小画面を表示させ,次に面積計算とToDoアプリの画面を順に実装します.

完成版の面積計算画面 完成版のToDo一覧画面

開発環境

筆者の開発環境は以下の通りです.

  • Windows 11
  • Debian 13 on WSL2(WSLg)
  • Flutter 3.44.9
  • Dart 3.12.2
  • Visual Studio Code(VS Code)

Flutter SDK本体は,公式の最新手順に従ってインストールします.Dart SDKはFlutter SDKに同梱されています.

VS Codeを使う場合は,Flutter拡張機能をインストールし,公式のVS Code向け手順からSDKを導入できます.

Flutterアプリの最小構成

最初に,Linuxデスクトップのみを対象としたFlutterプロジェクトを生成します.

$ flutter create --platforms=linux --project-name fluttergarden .

--platforms=linuxを指定しているため,ここではLinuxデスクトップ向けのファイルだけが生成されます.AndroidやiOSなどのフォルダ・ファイルは生成されません.

Flutterが自動生成するmain.dartには,ボタンを押すと数値が増えるカウンターアプリが実装されています.ルートWidgetはもともとStatelessWidgetですが,カウンター画面は変化する状態を持つStatefulWidgetとして実装されています.

ただ,この段階では状態管理を扱わず,いったんFlutterアプリを最小構成にするため,main.dartをほぼ全面的に書き換えます.カウンター機能とStatefulWidgetを取り除き,画面中央のボタンを押すとコンソールにログを出力するStatelessWidgetの画面に置き換えます.自動生成されたファイルやフォルダの削除は行いません.

lib/main.dart
import 'package:flutter/material.dart';

void main() {
  runApp(const FlutterGardenApp());
}

class FlutterGardenApp extends StatelessWidget {
  const FlutterGardenApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'fluttergarden',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
      ),
      home: const HomePage(),
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('fluttergarden 🌱')),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            debugPrint('Button clicked!');
          },
          child: const Text('Click Me'),
        ),
      ),
    );
  }
}

コードのポイント

  • main()はDartプログラムのエントリーポイントです
  • runApp()にルートWidgetを渡すと,FlutterがWidgetツリーを画面に描画します
  • StatelessWidgetは,Widget内に変化する状態を持たない場合に使います
  • build()は,現在の設定や状態から表示したいWidgetツリーを返します
  • MaterialAppはMaterial Designアプリ全体の設定を,Scaffoldは1画面の基本レイアウトを提供します
  • onPressedに関数を渡すことで,ボタン操作時の処理を定義します

この段階で表示内容は変化しないため,StatefulWidgetや状態管理はまだ使いません.

実行

$ flutter run -d linux

Click Meボタンを押すと,実行したターミナルに次のログが表示されます.

Button clicked!

Linuxネイティブウィンドウの設定

FlutterのLinuxデスクトップアプリでは,linux/runner/my_application.ccがGTKのネイティブウィンドウを生成します.このファイルはflutter createによって自動生成されますが,アプリのLinux固有のrunnerコードであるため,必要に応じて変更できます.

ここでは,初期ウィンドウサイズとタイトルバーの操作ボタンを変更します.どちらもFlutterのWidgetではなく,GTKが生成するLinuxネイティブウィンドウに対する設定です.

初期ウィンドウサイズ

flutter createで生成されたLinuxアプリは,初期ウィンドウサイズが1280×720に設定されています.今回の画面構成では横幅がやや広いため,1000×720へ変更します.

初期サイズは,gtk_window_set_default_size()で指定されています.

linux/runner/my_application.cc
gtk_window_set_default_size(window, 1000, 720);

これはウィンドウを開いたときの初期サイズであり,ユーザーによるリサイズを禁止する設定ではありません.

タイトルバーの操作ボタン

デスクトップ環境の設定によっては,自動生成直後のGTKタイトルバーに閉じるボタンだけが表示される場合があります.本開発環境では,最小化,最大化,閉じるボタンをGtkHeaderBarの右側へ明示するため,その生成部分に次の1行を追加します.

linux/runner/my_application.cc
gtk_header_bar_set_decoration_layout(header_bar, ":minimize,maximize,close");

以降の画面でも,この操作ボタンを表示したデザインを使用します.

変更後のウィンドウを実行すると,次のように表示されます.

Flutterアプリの最小画面

Linuxデスクトップアプリのビルド

flutter runは開発用の実行方法であり,ホットリロードやデバッグなどの機能を利用できます.一方,作成したアプリはLinux向けにビルドし,Flutterコマンドを介さず実行することもできます.

配布用のリリースビルドは,次のコマンドで作成します.

$ flutter build linux --release

ビルドが完了すると,build/linux/x64/release/bundle/に次のような成果物が生成されます.

bundle/
├── fluttergarden
├── lib/
└── data/

生成された実行ファイルは,次のように直接起動できます.

$ ./build/linux/x64/release/bundle/fluttergarden

起動時にFlutter SDKは必要ありません.ただし,実行ファイルだけで動作する構成ではなく,lib/data/の内容も必要です.別の環境に配布する場合は,bundleディレクトリ全体を配布します.また,配布先のLinux環境にはGTKなどのランタイムライブラリが必要です.

以降は実装と動作確認を効率よく繰り返すため,開発用のflutter run -d linuxを使用します.

面積計算

次に,高さと幅から面積を計算する画面を実装します.この画面では,次の操作を行えます.

  • HeightWidthのスライダーを0から100の範囲で操作する
  • Calculateボタンを押すと面積を計算する
  • Continuous calculationをONにすると,スライダーの操作時に面積を自動計算する

画面とアプリ設定の分割

面積計算画面は,次のように別ファイルへ分けて実装します.

lib/
├── main.dart
└── features/
    └── calculator/
        └── calculator_page.dart

main.dartはアプリ全体の設定に集中させ,最初に表示する画面としてCalculatorPageを指定します.

lib/main.dart
import 'package:flutter/material.dart';
import 'package:fluttergarden/features/calculator/calculator_page.dart';

void main() {
  runApp(const FlutterGardenApp());
}

class FlutterGardenApp extends StatelessWidget {
  const FlutterGardenApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'fluttergarden',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
      ),
      home: const CalculatorPage(),
    );
  }
}

StatefulWidgetとState

最小画面では表示内容が変化しなかったため,StatelessWidgetを使用しました.今回は,スライダーの値や計算結果を画面に保持する必要があるため,CalculatorPageStatefulWidgetとして実装します.

class CalculatorPage extends StatefulWidget {
  const CalculatorPage({super.key});

  @override
  State<CalculatorPage> createState() => _CalculatorPageState();
}

StatefulWidget自体も不変のWidgetです.変化する値は,対応するStateクラスが保持します.

class _CalculatorPageState extends State<CalculatorPage> {
  int _height = 0;
  int _width = 0;
  int _area = 0;
  bool _continuousCalculation = false;
}

Dartでは,名前の先頭に_を付けると,そのメンバーは同じライブラリ内からのみアクセスできるプライベートなメンバーになります.

setStateによる画面の更新

例えば,Heightスライダーが操作されたときは,次のメソッドを呼び出します.

void _setHeight(double value) {
  setState(() {
    _height = value.round();
    _updateAreaIfNeeded();
  });
}

setState()のコールバック内で状態を変更すると,FlutterはこのStatebuild()を再度呼び出します.画面のラベルを直接書き換えるのではなく,更新後の_heightを使ってWidgetツリーを再構築するのがFlutterの基本的な考え方です.

ユーザー操作
    ↓
setState()
    ↓
状態を変更
    ↓
build()を再実行
    ↓
新しい状態の画面を表示

FlutterのSliderdouble型の値を扱います.本サンプルでは整数の面積を計算するため,round()int型へ変換して状態に保存します.また,divisionsを指定して,スライダーが整数単位で停止するようにします.

計算処理

Calculateボタンが押された場合は,高さと幅を掛け合わせて面積を更新します.

void _calculateArea() {
  setState(() {
    _area = _height * _width;
  });
}

Continuous calculationが有効な場合は,高さまたは幅を変更するたびに面積も更新します.

void _updateAreaIfNeeded() {
  if (_continuousCalculation) {
    _area = _height * _width;
  }
}

画面の実装

面積計算画面の全体は次のようになります.

CalculatorPageのコード全体
lib/features/calculator/calculator_page.dart
import 'package:flutter/material.dart';

class CalculatorPage extends StatefulWidget {
  const CalculatorPage({super.key});

  @override
  State<CalculatorPage> createState() => _CalculatorPageState();
}

class _CalculatorPageState extends State<CalculatorPage> {
  static const int _maxDimension = 100;
  static const int _maxArea = _maxDimension * _maxDimension;

  int _height = 0;
  int _width = 0;
  int _area = 0;
  bool _continuousCalculation = false;

  void _setHeight(double value) {
    setState(() {
      _height = value.round();
      _updateAreaIfNeeded();
    });
  }

  void _setWidth(double value) {
    setState(() {
      _width = value.round();
      _updateAreaIfNeeded();
    });
  }

  void _setContinuousCalculation(bool value) {
    setState(() {
      _continuousCalculation = value;
      _updateAreaIfNeeded();
    });
  }

  void _calculateArea() {
    setState(() {
      _area = _height * _width;
    });
  }

  void _updateAreaIfNeeded() {
    if (_continuousCalculation) {
      _area = _height * _width;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('fluttergarden 🌱')),
      body: Center(
        child: SingleChildScrollView(
          padding: const EdgeInsets.all(32),
          child: ConstrainedBox(
            constraints: const BoxConstraints(maxWidth: 640),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                Row(
                  children: [
                    Expanded(
                      child: _DimensionSlider(
                        label: 'Height',
                        value: _height,
                        max: _maxDimension,
                        onChanged: _setHeight,
                      ),
                    ),
                    const SizedBox(width: 32),
                    Expanded(
                      child: _DimensionSlider(
                        label: 'Width',
                        value: _width,
                        max: _maxDimension,
                        onChanged: _setWidth,
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: 24),
                SwitchListTile(
                  contentPadding: EdgeInsets.zero,
                  title: const Text('Continuous calculation'),
                  value: _continuousCalculation,
                  onChanged: _setContinuousCalculation,
                ),
                const SizedBox(height: 16),
                Align(
                  alignment: Alignment.centerLeft,
                  child: FilledButton(
                    onPressed: _calculateArea,
                    child: const Text('Calculate'),
                  ),
                ),
                const SizedBox(height: 32),
                Text('Area', style: Theme.of(context).textTheme.titleMedium),
                const SizedBox(height: 8),
                Text(
                  '$_area',
                  style: Theme.of(context).textTheme.headlineSmall,
                ),
                Slider(
                  value: _area.toDouble(),
                  max: _maxArea.toDouble(),
                  onChanged: null,
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class _DimensionSlider extends StatelessWidget {
  const _DimensionSlider({
    required this.label,
    required this.value,
    required this.max,
    required this.onChanged,
  });

  final String label;
  final int value;
  final int max;
  final ValueChanged<double> onChanged;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(label, style: Theme.of(context).textTheme.titleMedium),
        const SizedBox(height: 8),
        Text('$value'),
        Slider(
          value: value.toDouble(),
          max: max.toDouble(),
          divisions: max,
          onChanged: onChanged,
        ),
      ],
    );
  }
}

HeightWidthは,ラベル・値・スライダーという同じUI構成を使います.そのため,共通部分を_DimensionSliderという別のStatelessWidgetとして切り出しています.親のCalculatorPageから値とコールバックを受け取るだけで,_DimensionSlider自身は状態を保持しません.

実行すると,次のようにスライダーの値と計算結果が画面に反映されます.

setStateで実装した面積計算画面

現在はCalculatorPageがUIの状態と面積の計算ロジックの両方を持っています.小さな画面ではシンプルな構成ですが,処理が増えるとWidgetの責務が大きくなり,ロジックのテストもUIに依存します.次の段階では,状態と計算処理をViewModelへ分離します.

MVVMへのリファクタリング

setState()版では,CalculatorPageStateがUIの状態と計算処理を保持していました.ここからは,それらをCalculatorViewModelへ移し,ViewとViewModelの責務を分けます.

CalculatorPage(View)
    │ ユーザー操作
    ↓
CalculatorViewModel
    │ 状態変更を通知
    ↓
CalculatorPageを再ビルド

このサンプルの計算ロジックは小さいため,独立したModelクラスは作成せず,画面の状態と一緒にViewModelが保持します.

providerの追加

ViewModelをWidgetツリーへ提供し,Viewから取得するため,providerパッケージを追加します.

$ flutter pub add provider

ChangeNotifierはFlutter SDKに含まれる変更通知の仕組みで,providerはそのインスタンスをWidgetツリーへ提供し,適切に取得するために使用します.

ViewModelの実装

CalculatorViewModelChangeNotifierを継承し,状態はプライベートなフィールド,Viewへ公開する値は読み取り専用のgetterとして実装します.

lib/features/calculator/calculator_view_model.dart
import 'package:flutter/foundation.dart';

class CalculatorViewModel extends ChangeNotifier {
  static const int maxDimension = 100;
  static const int maxArea = maxDimension * maxDimension;

  int _height = 0;
  int _width = 0;
  int _area = 0;
  bool _continuousCalculation = false;

  int get height => _height;
  int get width => _width;
  int get area => _area;
  bool get continuousCalculation => _continuousCalculation;

  void setHeight(int value) {
    if (_height == value) {
      return;
    }

    _height = value;
    _updateAreaIfNeeded();
    notifyListeners();
  }

  void setWidth(int value) {
    if (_width == value) {
      return;
    }

    _width = value;
    _updateAreaIfNeeded();
    notifyListeners();
  }

  void setContinuousCalculation(bool value) {
    if (_continuousCalculation == value) {
      return;
    }

    _continuousCalculation = value;
    _updateAreaIfNeeded();
    notifyListeners();
  }

  void calculateArea() {
    final newArea = _height * _width;
    if (_area == newArea) {
      return;
    }

    _area = newArea;
    notifyListeners();
  }

  void _updateAreaIfNeeded() {
    if (_continuousCalculation) {
      _area = _height * _width;
    }
  }
}

notifyListeners()を呼び出すと,ChangeNotifierに登録されたリスナーへ状態変更が通知されます.この通知は,providerを介してWidgetの再ビルドにつながります.同じ値が設定された場合や,計算結果が変わらない場合はreturnで処理を終了し,不要な通知を行わないように実装します.

ViewModelの提供

main.dartChangeNotifierProviderを使い,CalculatorPageより上位のWidgetツリーへViewModelを配置します.

import 'package:fluttergarden/features/calculator/calculator_view_model.dart';
import 'package:provider/provider.dart';

// 省略

home: ChangeNotifierProvider(
  create: (context) => CalculatorViewModel(),
  child: const CalculatorPage(),
),

createで作成されたViewModelは,このChangeNotifierProvider配下のWidgetから取得できます.また,ChangeNotifierProviderは生成したViewModelが不要になったときのdispose()も管理します.

Viewの更新

ViewModelが状態を保持するため,CalculatorPageStatefulWidgetである必要がなくなり,StatelessWidgetへ変更できます.

import 'package:fluttergarden/features/calculator/calculator_view_model.dart';
import 'package:provider/provider.dart';

// 省略

class CalculatorPage extends StatelessWidget {
  const CalculatorPage({super.key});

  @override
  Widget build(BuildContext context) {
    final viewModel = context.watch<CalculatorViewModel>();

context.watch<CalculatorViewModel>()は,最も近いProviderからViewModelを取得し,その変更を監視します.ViewModelがnotifyListeners()を呼び出すと,CalculatorPagebuild()が再度実行されます.

Viewは,ViewModelのgetterから表示する値を取得します.

Text('${viewModel.area}')

ユーザー操作は,ViewModelのメソッドへ伝えます.

child: FilledButton(
  onPressed: viewModel.calculateArea,
  child: const Text('Calculate'),
),

Sliderdouble型をint型へ変換する処理は,FlutterのUI部品に由来するためView側に残します.ViewModelは,スライダーの仕様を意識せず,アプリで扱う整数の高さと幅だけを受け取ります.

onChanged: (value) {
  viewModel.setHeight(value.round());
},

このように,ViewModelからViewへはgetterとcontext.watch()を通じて状態を反映し,ViewからViewModelへはコールバックからメソッドを呼び出します.Flutterによって自動的に双方向バインディングされるのではなく,2つの方向を明示的に接続しています.

ViewModelのUnit Test

状態と計算処理をViewModelへ分離したことで,Widgetを構築せずにロジックをテストできます.テストに使用するflutter_testは,flutter createで生成されたプロジェクトにあらかじめ設定されています.test()のコールバックに1つのテストを記述し,expect()で実際の値が期待する値と一致するか確認します.

test/features/calculator/calculator_view_model_test.dart
test('高さと幅から面積を計算できる', () {
  viewModel.setHeight(10);
  viewModel.setWidth(20);

  expect(viewModel.area, 0);

  viewModel.calculateArea();

  expect(viewModel.area, 200);
});

連続計算や初期状態に加え,値が実際に変更されたときだけリスナーへ通知されることもUnit Testで確認します.

ViewModelのUnit Testのコード全体
test/features/calculator/calculator_view_model_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:fluttergarden/features/calculator/calculator_view_model.dart';

void main() {
  late CalculatorViewModel viewModel;

  setUp(() {
    viewModel = CalculatorViewModel();
  });

  tearDown(() {
    viewModel.dispose();
  });

  test('初期状態が正しい', () {
    expect(viewModel.height, 0);
    expect(viewModel.width, 0);
    expect(viewModel.area, 0);
    expect(viewModel.continuousCalculation, isFalse);
  });

  test('高さと幅から面積を計算できる', () {
    viewModel.setHeight(10);
    viewModel.setWidth(20);

    expect(viewModel.area, 0);

    viewModel.calculateArea();

    expect(viewModel.area, 200);
  });

  test('連続計算で入力時に面積を更新できる', () {
    viewModel.setContinuousCalculation(true);
    viewModel.setHeight(30);
    viewModel.setWidth(20);

    expect(viewModel.area, 600);
  });

  test('状態が変更されたときだけリスナーへ通知する', () {
    var notificationCount = 0;
    viewModel.addListener(() {
      notificationCount++;
    });

    viewModel.setHeight(10);
    expect(notificationCount, 1);

    viewModel.setHeight(10);
    expect(notificationCount, 1);

    viewModel.setWidth(20);
    expect(notificationCount, 2);

    viewModel.calculateArea();
    expect(notificationCount, 3);

    viewModel.calculateArea();
    expect(notificationCount, 3);
  });
}

lateはViewModelが後から初期化されることを表します.setUp()は各テストの実行前に新しいViewModelを作成し,テスト間で状態が共有されることを防ぎます.tearDown()は各テストの実行後に呼び出され,ViewModelをdispose()します.

通知のテストでは,addListener()でリスナーを直接登録します.notifyListeners()が呼び出されるたびにカウントを増やすことで,実際に状態が変わったときだけ通知されることを確認しています.

このタイミングで,flutter createによって生成されたwidget_test.dartも,現在の画面に合わせて更新します.Unit TestがViewModelを単体で確認するのに対し,Widget TestではWidgetを構築し,表示やユーザー操作を確認します.

Widget Testのコード全体
test/widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

import 'package:fluttergarden/main.dart';

void main() {
  testWidgets('面積を計算できる', (WidgetTester tester) async {
    await tester.pumpWidget(const FlutterGardenApp());

    expect(find.text('Height'), findsOneWidget);
    expect(find.text('Width'), findsOneWidget);
    expect(find.text('Area'), findsOneWidget);

    final sliders = find.byType(Slider);
    final heightSlider = tester.widget<Slider>(sliders.at(0));
    final widthSlider = tester.widget<Slider>(sliders.at(1));

    heightSlider.onChanged!(10);
    await tester.pump();
    widthSlider.onChanged!(20);
    await tester.pump();

    expect(find.text('200'), findsNothing);

    await tester.tap(find.text('Calculate'));
    await tester.pump();

    expect(find.text('200'), findsOneWidget);
  });

  testWidgets('連続計算で入力時に面積を更新できる', (WidgetTester tester) async {
    await tester.pumpWidget(const FlutterGardenApp());

    await tester.tap(find.byType(Switch));
    await tester.pump();

    final sliders = find.byType(Slider);
    final heightSlider = tester.widget<Slider>(sliders.at(0));
    final widthSlider = tester.widget<Slider>(sliders.at(1));

    heightSlider.onChanged!(30);
    await tester.pump();
    widthSlider.onChanged!(20);
    await tester.pump();

    expect(find.text('600'), findsOneWidget);
  });
}

testWidgets()のコールバックは,画面を操作するためのWidgetTesterを受け取ります.pumpWidget()でテスト対象のアプリを構築し,findでWidgetを検索します.操作後にpump()を呼び出すと,変更後の状態を画面へ反映できます.

プロジェクト内のUnit TestとWidget Testは,次のコマンドでまとめて実行できます.

$ flutter test

画面の見た目や操作は変えずに,Viewは表示とユーザー操作の受け渡しに,ViewModelは状態と計算処理に集中できる構成へリファクタリングできました.

2画面を持つアプリの土台

次は,面積計算とToDoを1つのアプリに収めるため,2画面を切り替えられる土台を作ります.アプリ全体のHomePageScaffoldを持ち,各ページは本文部分だけを担当します.

HomePage
├─ AppBar
├─ PageView
│  ├─ CalculatorPage
│  └─ TodoPage
└─ NavigationBar

PageViewは複数のページを横に並べ,ドラッグやタッチ操作で切り替えられるWidgetです.NavigationBarは,現在のページと移動先を画面下部に表示します.

ただし,FlutterのデフォルトのScrollBehaviorでは,デスクトップのマウスドラッグは対象外です.これは,スクロール可能な領域でのテキスト選択と競合しないための仕様です.今回はデスクトップアプリの操作性を優先し,このPageViewに限定してマウスを追加します.

scrollBehavior: const MaterialScrollBehavior().copyWith(
  dragDevices: {
    PointerDeviceKind.touch,
    PointerDeviceKind.stylus,
    PointerDeviceKind.invertedStylus,
    PointerDeviceKind.trackpad,
    PointerDeviceKind.mouse,
  },
),

これにより,タッチやトラックパッドの操作を保ったまま,マウスの左ボタンでページ部分を左右にドラッグできます.

PageControllerのライフサイクル

PageControllerPageViewをコードから操作するためのオブジェクトです.HomePageが表示されている間は同じインスタンスを使うため,initState()で作成します.

late final PageController _pageController;

@override
void initState() {
  super.initState();
  _pageController = PageController();
}

Controllerは画面が破棄されるときにdispose()し,使用していたリソースを解放します.

@override
void dispose() {
  _pageController.dispose();
  super.dispose();
}

PageViewとNavigationBarの同期

PageViewの表示ページが変わったときは,onPageChangedで現在のインデックスを更新します.

onPageChanged: (index) {
  setState(() {
    _currentPageIndex = index;
  });
},

NavigationBarが選択されたときは,PageControllerを使って対応するページへ移動します.

onDestinationSelected: (index) {
  _pageController.animateToPage(
    index,
    duration: const Duration(milliseconds: 300),
    curve: Curves.easeInOut,
  );
},

_currentPageIndexは画面切り替えのためだけに使う,HomePage内の一時的なUI状態です.そのため,ViewModelへ移さずsetState()で管理します.MVVMを採用しても,すべての状態をViewModelに入れる必要はありません.

CalculatorViewModelHomePageより上位のProviderが保持しているため,ToDo画面へ移動して戻っても,面積計算の値は保持されます.

画面のコード全体
lib/main.dart
import 'package:flutter/material.dart';
import 'package:fluttergarden/features/calculator/calculator_view_model.dart';
import 'package:fluttergarden/features/home/home_page.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(const FlutterGardenApp());
}

class FlutterGardenApp extends StatelessWidget {
  const FlutterGardenApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'fluttergarden',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
      ),
      home: ChangeNotifierProvider(
        create: (context) => CalculatorViewModel(),
        child: const HomePage(),
      ),
    );
  }
}
lib/features/home/home_page.dart
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:fluttergarden/features/calculator/calculator_page.dart';
import 'package:fluttergarden/features/todo/todo_page.dart';

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  late final PageController _pageController;
  int _currentPageIndex = 0;

  @override
  void initState() {
    super.initState();
    _pageController = PageController();
  }

  @override
  void dispose() {
    _pageController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('fluttergarden 🌱')),
      body: PageView(
        controller: _pageController,
        scrollBehavior: const MaterialScrollBehavior().copyWith(
          dragDevices: {
            PointerDeviceKind.touch,
            PointerDeviceKind.stylus,
            PointerDeviceKind.invertedStylus,
            PointerDeviceKind.trackpad,
            PointerDeviceKind.mouse,
          },
        ),
        onPageChanged: (index) {
          setState(() {
            _currentPageIndex = index;
          });
        },
        children: const [CalculatorPage(), TodoPage()],
      ),
      bottomNavigationBar: NavigationBar(
        selectedIndex: _currentPageIndex,
        onDestinationSelected: (index) {
          _pageController.animateToPage(
            index,
            duration: const Duration(milliseconds: 300),
            curve: Curves.easeInOut,
          );
        },
        destinations: const [
          NavigationDestination(
            icon: Icon(Icons.calculate_outlined),
            selectedIcon: Icon(Icons.calculate),
            label: 'Calculator',
          ),
          NavigationDestination(
            icon: Icon(Icons.checklist_outlined),
            selectedIcon: Icon(Icons.checklist),
            label: 'ToDo',
          ),
        ],
      ),
    );
  }
}
lib/features/calculator/calculator_page.dart
import 'package:flutter/material.dart';
import 'package:fluttergarden/features/calculator/calculator_view_model.dart';
import 'package:provider/provider.dart';

class CalculatorPage extends StatelessWidget {
  const CalculatorPage({super.key});

  @override
  Widget build(BuildContext context) {
    final viewModel = context.watch<CalculatorViewModel>();

    return Center(
      child: SingleChildScrollView(
        padding: const EdgeInsets.all(32),
        child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 640),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Row(
                children: [
                  Expanded(
                    child: _DimensionSlider(
                      label: 'Height',
                      value: viewModel.height,
                      max: CalculatorViewModel.maxDimension,
                      onChanged: (value) {
                        viewModel.setHeight(value.round());
                      },
                    ),
                  ),
                  const SizedBox(width: 32),
                  Expanded(
                    child: _DimensionSlider(
                      label: 'Width',
                      value: viewModel.width,
                      max: CalculatorViewModel.maxDimension,
                      onChanged: (value) {
                        viewModel.setWidth(value.round());
                      },
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 24),
              SwitchListTile(
                contentPadding: EdgeInsets.zero,
                title: const Text('Continuous calculation'),
                value: viewModel.continuousCalculation,
                onChanged: viewModel.setContinuousCalculation,
              ),
              const SizedBox(height: 16),
              Align(
                alignment: Alignment.centerLeft,
                child: FilledButton(
                  onPressed: viewModel.calculateArea,
                  child: const Text('Calculate'),
                ),
              ),
              const SizedBox(height: 32),
              Text('Area', style: Theme.of(context).textTheme.titleMedium),
              const SizedBox(height: 8),
              Text(
                '${viewModel.area}',
                style: Theme.of(context).textTheme.headlineSmall,
              ),
              Slider(
                value: viewModel.area.toDouble(),
                max: CalculatorViewModel.maxArea.toDouble(),
                onChanged: null,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _DimensionSlider extends StatelessWidget {
  const _DimensionSlider({
    required this.label,
    required this.value,
    required this.max,
    required this.onChanged,
  });

  final String label;
  final int value;
  final int max;
  final ValueChanged<double> onChanged;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(label, style: Theme.of(context).textTheme.titleMedium),
        const SizedBox(height: 8),
        Text('$value'),
        Slider(
          value: value.toDouble(),
          max: max.toDouble(),
          divisions: max,
          onChanged: onChanged,
        ),
      ],
    );
  }
}
lib/features/todo/todo_page.dart
import 'package:flutter/material.dart';

class TodoPage extends StatelessWidget {
  const TodoPage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Center(child: Text('ToDo is coming soon!'));
  }
}

NavigationBarからToDo画面へ切り替えると,次のように表示されます.この段階では,ToDo画面はプレースホルダーのみです.

ToDo画面を選択した2画面構成のアプリ

ToDoアプリ

ここからは,複数のデータを扱うToDoアプリを実装します.まずは永続化を行わず,メモリ上でToDoの追加・完了状態の切り替え・削除・絞り込みができる構成を作ります.

TodoPage(View)
    │ ユーザー操作
    ↓
TodoViewModel
    │ List<Todo>を更新して通知
    ↓
TodoPageを再ビルド

不変なTodo Model

Todoは,一意なID・表示するタイトル・完了状態を持ちます.すべてのフィールドをfinalにし,作成後に書き換えられない不変オブジェクトとして実装します.

lib/features/todo/todo.dart
class Todo {
  const Todo({
    required this.id,
    required this.title,
    this.completed = false,
  });

  final int id;
  final String title;
  final bool completed;

  Todo copyWith({String? title, bool? completed}) {
    return Todo(
      id: id,
      title: title ?? this.title,
      completed: completed ?? this.completed,
    );
  }
}

完了状態を変える場合は,元のTodoを書き換えず,copyWith()で変更後の新しいTodoを作成します.状態の変更前と変更後が別オブジェクトになるため,変化を追跡しやすくなります.

TodoViewModelでListを管理する

今回は,Todoは不変にしますが,ViewModel内部のListは可変として扱います.1件を更新するたびにList全体をコピーせず,対象のTodoだけを新しいオブジェクトへ置き換えます.

final List<Todo> _todos = [];
late final List<Todo> _readonlyTodos = UnmodifiableListView(_todos);

List<Todo> get todos => _readonlyTodos;

UnmodifiableListViewは,元のListをコピーせず,読み取り専用のViewとして公開します.ViewはToDoの一覧を読めますが,add()remove()で直接変更できません.状態を変更する入口はViewModelのメソッドに集約されます.

ToDoを追加するときは,前後の空白を取り除き,空文字列でない場合だけListへ追加します.

bool addTodo(String title) {
  final trimmedTitle = title.trim();
  if (trimmedTitle.isEmpty) {
    return false;
  }

  _todos.add(Todo(id: _nextId++, title: trimmedTitle));
  notifyListeners();
  return true;
}

戻り値は,Viewが入力欄をクリアするか判断するために使います.入力値の検証はViewModelで,入力欄の操作はViewで担当します.

完了状態の切り替えでは,IDから対象の位置を探し,copyWith()で作った新しいTodoへ置き換えます.

void toggleTodo(int id) {
  final index = _todos.indexWhere((todo) => todo.id == id);
  if (index == -1) {
    return;
  }

  final todo = _todos[index];
  _todos[index] = todo.copyWith(completed: !todo.completed);
  notifyListeners();
}

この検索はO(n)ですが,学習用の小規模なToDoでは分かりやすさを優先します.大量のデータを高頻度で更新する場合は,IDをキーにしたMapなども検討します.

表示用リストを導出する

未完了だけを表示する場合も,元のListから完了済みToDoを削除しません.フィルター状態から,表示対象となるToDoを導出します.

List<Todo> get visibleTodos => _showOnlyIncomplete
    ? _todos.where((todo) => !todo.completed).toList(growable: false)
    : _readonlyTodos;

絞り込みが無効な場合は読み取り専用Viewをそのまま返し,List全体をコピーしません.絞り込み中だけ表示用Listを作成します.元データは保持されるため,絞り込みを解除すると完了済みToDoも再び表示されます.

MultiProviderで複数のViewModelを提供する

面積計算とToDoの2つのViewModelを提供するため,main.dartMultiProviderへ変更します.

home: MultiProvider(
  providers: [
    ChangeNotifierProvider(
      create: (context) => CalculatorViewModel(),
    ),
    ChangeNotifierProvider(create: (context) => TodoViewModel()),
  ],
  child: const HomePage(),
),

MultiProviderは,複数のProviderを読みやすく並べるためのWidgetです.各ViewModelはそれぞれのChangeNotifierProviderが生成と破棄を管理します.

TodoPageの実装

TodoPagecontext.watch<TodoViewModel>()で状態を監視します.一覧はListView.builderで構築するため,データが増えても表示範囲付近のWidgetを中心に作成できます.

ListView.builder(
  itemCount: visibleTodos.length,
  itemBuilder: (context, index) {
    final todo = visibleTodos[index];
    return _TodoListItem(
      key: ValueKey(todo.id),
      todo: todo,
      onToggle: () => viewModel.toggleTodo(todo.id),
      onRemove: () => viewModel.removeTodo(todo.id),
    );
  },
)

ValueKey(todo.id)により,追加や削除で行番号が変わっても,Flutterは各WidgetとToDoの対応を識別できます.

入力欄のTextEditingControllerは,ToDoのデータではなくFlutterのUI部品を操作するための状態です.そのためViewModelではなくTodoPageが保持し,initState()で生成してdispose()で破棄します.

ToDoアプリのコード全体
lib/main.dart
import 'package:flutter/material.dart';
import 'package:fluttergarden/features/calculator/calculator_view_model.dart';
import 'package:fluttergarden/features/home/home_page.dart';
import 'package:fluttergarden/features/todo/todo_view_model.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(const FlutterGardenApp());
}

class FlutterGardenApp extends StatelessWidget {
  const FlutterGardenApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'fluttergarden',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
      ),
      home: MultiProvider(
        providers: [
          ChangeNotifierProvider(create: (context) => CalculatorViewModel()),
          ChangeNotifierProvider(create: (context) => TodoViewModel()),
        ],
        child: const HomePage(),
      ),
    );
  }
}
lib/features/todo/todo_page.dart
import 'package:flutter/material.dart';
import 'package:fluttergarden/features/todo/todo.dart';
import 'package:fluttergarden/features/todo/todo_view_model.dart';
import 'package:provider/provider.dart';

class TodoPage extends StatefulWidget {
  const TodoPage({super.key});

  @override
  State<TodoPage> createState() => _TodoPageState();
}

class _TodoPageState extends State<TodoPage> {
  late final TextEditingController _textController;

  @override
  void initState() {
    super.initState();
    _textController = TextEditingController();
  }

  @override
  void dispose() {
    _textController.dispose();
    super.dispose();
  }

  void _addTodo(TodoViewModel viewModel) {
    if (viewModel.addTodo(_textController.text)) {
      _textController.clear();
    }
  }

  @override
  Widget build(BuildContext context) {
    final viewModel = context.watch<TodoViewModel>();
    final visibleTodos = viewModel.visibleTodos;

    return Center(
      child: Padding(
        padding: const EdgeInsets.all(32),
        child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 640),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Row(
                children: [
                  Expanded(
                    child: TextField(
                      controller: _textController,
                      decoration: const InputDecoration(
                        border: OutlineInputBorder(),
                        labelText: 'New ToDo',
                      ),
                      onSubmitted: (_) => _addTodo(viewModel),
                    ),
                  ),
                  const SizedBox(width: 16),
                  FilledButton(
                    onPressed: () => _addTodo(viewModel),
                    child: const Text('Add'),
                  ),
                ],
              ),
              const SizedBox(height: 16),
              SwitchListTile(
                contentPadding: EdgeInsets.zero,
                title: const Text('Show only incomplete'),
                value: viewModel.showOnlyIncomplete,
                onChanged: viewModel.setShowOnlyIncomplete,
              ),
              const SizedBox(height: 8),
              Expanded(
                child: visibleTodos.isEmpty
                    ? const Center(child: Text('No ToDos'))
                    : ListView.builder(
                        itemCount: visibleTodos.length,
                        itemBuilder: (context, index) {
                          final todo = visibleTodos[index];
                          return _TodoListItem(
                            key: ValueKey(todo.id),
                            todo: todo,
                            onToggle: () => viewModel.toggleTodo(todo.id),
                            onRemove: () => viewModel.removeTodo(todo.id),
                          );
                        },
                      ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _TodoListItem extends StatelessWidget {
  const _TodoListItem({
    super.key,
    required this.todo,
    required this.onToggle,
    required this.onRemove,
  });

  final Todo todo;
  final VoidCallback onToggle;
  final VoidCallback onRemove;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      leading: Checkbox(value: todo.completed, onChanged: (_) => onToggle()),
      title: Text(
        todo.title,
        style: TextStyle(
          decoration: todo.completed ? TextDecoration.lineThrough : null,
        ),
      ),
      trailing: IconButton(
        tooltip: 'Delete',
        onPressed: onRemove,
        icon: const Icon(Icons.delete_outline),
      ),
    );
  }
}
lib/features/todo/todo_view_model.dart
import 'dart:collection';

import 'package:flutter/foundation.dart';
import 'package:fluttergarden/features/todo/todo.dart';

class TodoViewModel extends ChangeNotifier {
  final List<Todo> _todos = [];
  late final List<Todo> _readonlyTodos = UnmodifiableListView(_todos);

  int _nextId = 0;
  bool _showOnlyIncomplete = false;

  List<Todo> get todos => _readonlyTodos;
  bool get showOnlyIncomplete => _showOnlyIncomplete;
  List<Todo> get visibleTodos => _showOnlyIncomplete
      ? _todos.where((todo) => !todo.completed).toList(growable: false)
      : _readonlyTodos;

  bool addTodo(String title) {
    final trimmedTitle = title.trim();
    if (trimmedTitle.isEmpty) {
      return false;
    }

    _todos.add(Todo(id: _nextId++, title: trimmedTitle));
    notifyListeners();
    return true;
  }

  void toggleTodo(int id) {
    final index = _todos.indexWhere((todo) => todo.id == id);
    if (index == -1) {
      return;
    }

    final todo = _todos[index];
    _todos[index] = todo.copyWith(completed: !todo.completed);
    notifyListeners();
  }

  void removeTodo(int id) {
    final index = _todos.indexWhere((todo) => todo.id == id);
    if (index == -1) {
      return;
    }

    _todos.removeAt(index);
    notifyListeners();
  }

  void setShowOnlyIncomplete(bool value) {
    if (_showOnlyIncomplete == value) {
      return;
    }

    _showOnlyIncomplete = value;
    notifyListeners();
  }
}
lib/features/todo/todo.dart
class Todo {
  const Todo({required this.id, required this.title, this.completed = false});

  final int id;
  final String title;
  final bool completed;

  Todo copyWith({String? title, bool? completed}) {
    return Todo(
      id: id,
      title: title ?? this.title,
      completed: completed ?? this.completed,
    );
  }
}

実行すると,追加・完了状態の切り替え・削除・絞り込みができるToDo画面が表示されます.

完了済みと未完了のToDoを表示したToDo画面

TodoViewModelのUnit Test

Unit Testでは,ToDoの追加・空文字列の拒否・完了状態の切り替え・削除・絞り込みを確認します.また,完了状態の切り替え後に別のTodoインスタンスになっていることも確認します.

final originalTodo = viewModel.todos.single;

viewModel.toggleTodo(originalTodo.id);

expect(viewModel.todos.single.completed, isTrue);
expect(identical(viewModel.todos.single, originalTodo), isFalse);

identical()は,2つの参照が同じオブジェクトを指しているかを判定します.

アプリ全体のWidget Test

Widget Testには,画面の切り替えやToDoの追加・完了・削除を一連の操作として確認するテストを追加します.

Unit TestとWidget Testのコード全体
test/features/todo/todo_view_model_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:fluttergarden/features/todo/todo.dart';
import 'package:fluttergarden/features/todo/todo_view_model.dart';

void main() {
  late TodoViewModel viewModel;

  setUp(() {
    viewModel = TodoViewModel();
  });

  test('初期状態でToDoが空', () {
    expect(viewModel.todos, isEmpty);
    expect(viewModel.showOnlyIncomplete, isFalse);
  });

  test('ToDoを追加できる', () {
    expect(viewModel.addTodo('  Buy milk  '), isTrue);

    expect(viewModel.todos, hasLength(1));
    expect(viewModel.todos.single.title, 'Buy milk');
    expect(viewModel.todos.single.completed, isFalse);
  });

  test('空白だけのToDoは追加しない', () {
    expect(viewModel.addTodo('   '), isFalse);
    expect(viewModel.todos, isEmpty);
  });

  test('ToDoの完了状態を切り替えられる', () {
    viewModel.addTodo('Buy milk');
    final originalTodo = viewModel.todos.single;

    viewModel.toggleTodo(originalTodo.id);

    expect(viewModel.todos.single.completed, isTrue);
    expect(identical(viewModel.todos.single, originalTodo), isFalse);
  });

  test('ToDoを削除できる', () {
    viewModel.addTodo('Buy milk');
    final id = viewModel.todos.single.id;

    viewModel.removeTodo(id);

    expect(viewModel.todos, isEmpty);
  });

  test('未完了のToDoだけを取得できる', () {
    viewModel.addTodo('Completed');
    viewModel.addTodo('Incomplete');
    viewModel.toggleTodo(viewModel.todos.first.id);

    viewModel.setShowOnlyIncomplete(true);

    expect(viewModel.visibleTodos.map((todo) => todo.title), ['Incomplete']);
    expect(viewModel.todos, hasLength(2));
  });

  test('公開されたListは変更できない', () {
    expect(
      () => viewModel.todos.add(const Todo(id: 0, title: 'Buy milk')),
      throwsUnsupportedError,
    );
  });
}
test/widget_test.dart
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

import 'package:fluttergarden/main.dart';

void main() {
  testWidgets('面積を計算できる', (WidgetTester tester) async {
    await tester.pumpWidget(const FlutterGardenApp());

    expect(find.text('Height'), findsOneWidget);
    expect(find.text('Width'), findsOneWidget);
    expect(find.text('Area'), findsOneWidget);

    final sliders = find.byType(Slider);
    final heightSlider = tester.widget<Slider>(sliders.at(0));
    final widthSlider = tester.widget<Slider>(sliders.at(1));

    heightSlider.onChanged!(10);
    await tester.pump();
    widthSlider.onChanged!(20);
    await tester.pump();

    expect(find.text('200'), findsNothing);

    await tester.tap(find.text('Calculate'));
    await tester.pump();

    expect(find.text('200'), findsOneWidget);
  });

  testWidgets('連続計算で入力時に面積を更新できる', (WidgetTester tester) async {
    await tester.pumpWidget(const FlutterGardenApp());

    await tester.tap(find.byType(Switch));
    await tester.pump();

    final sliders = find.byType(Slider);
    final heightSlider = tester.widget<Slider>(sliders.at(0));
    final widthSlider = tester.widget<Slider>(sliders.at(1));

    heightSlider.onChanged!(30);
    await tester.pump();
    widthSlider.onChanged!(20);
    await tester.pump();

    expect(find.text('600'), findsOneWidget);
  });

  testWidgets('ナビゲーションでToDo画面へ移動できる', (WidgetTester tester) async {
    await tester.pumpWidget(const FlutterGardenApp());

    await tester.tap(find.text('ToDo'));
    await tester.pumpAndSettle();

    expect(find.text('New ToDo'), findsOneWidget);
  });

  testWidgets('マウスドラッグでToDo画面へ移動できる', (WidgetTester tester) async {
    await tester.pumpWidget(const FlutterGardenApp());

    final gesture = await tester.startGesture(
      tester.getCenter(find.byType(PageView)),
      kind: PointerDeviceKind.mouse,
    );
    await gesture.moveBy(const Offset(-500, 0));
    await gesture.up();
    await tester.pumpAndSettle();

    expect(find.text('New ToDo'), findsOneWidget);
  });

  testWidgets('ToDoを追加して完了と削除を操作できる', (WidgetTester tester) async {
    await tester.pumpWidget(const FlutterGardenApp());
    await tester.tap(find.text('ToDo'));
    await tester.pumpAndSettle();

    await tester.enterText(find.byType(TextField), 'Buy milk');
    await tester.tap(find.text('Add'));
    await tester.pump();

    expect(find.text('Buy milk'), findsOneWidget);
    expect(find.text('No ToDos'), findsNothing);

    await tester.tap(find.byType(Checkbox));
    await tester.pump();
    final todoText = tester.widget<Text>(find.text('Buy milk'));
    expect(todoText.style?.decoration, TextDecoration.lineThrough);

    await tester.tap(find.byTooltip('Delete'));
    await tester.pump();
    expect(find.text('Buy milk'), findsNothing);
    expect(find.text('No ToDos'), findsOneWidget);
  });

  testWidgets('画面を移動しても面積計算の状態を保持する', (WidgetTester tester) async {
    await tester.pumpWidget(const FlutterGardenApp());

    final sliders = find.byType(Slider);
    tester.widget<Slider>(sliders.at(0)).onChanged!(10);
    await tester.pump();
    tester.widget<Slider>(sliders.at(1)).onChanged!(20);
    await tester.pump();
    await tester.tap(find.text('Calculate'));
    await tester.pump();

    await tester.tap(find.text('ToDo'));
    await tester.pumpAndSettle();
    await tester.tap(find.text('Calculator'));
    await tester.pumpAndSettle();

    expect(find.text('200'), findsOneWidget);
  });
}

Unit TestとWidget Testは,これまでと同じコマンドでまとめて実行できます.

$ flutter test

この段階ではデータはメモリ上だけにあるため,アプリを終了するとToDoは消えます.

Repositoryへの分離とJSON永続化

次に,ToDoの保存先をViewModelから分離します.ViewModelがメモリ・ファイル・データベースなどの具体的な保存方法を知らない構成にします.

TodoPage
    ↓ 操作
TodoViewModel
    ↓ 読み込み・保存を依頼
TodoRepository(抽象)
    ↓
JsonTodoRepository
    ↓ JSONファイルを読み書き
todos.json

Repositoryのインターフェース

Repositoryのインターフェースでは,ToDo一覧の読み込みと保存の2つを用意します.

lib/features/todo/todo_repository.dart
import 'package:fluttergarden/features/todo/todo.dart';

abstract interface class TodoRepository {
  Future<List<Todo>> loadTodos();

  Future<void> saveTodos(List<Todo> todos);
}

abstract interface classは,このクラスを実体化せず,実装クラスが守るインターフェースとして使うことを表します.戻り値をFutureにすることで,ファイルやデータベースなどの非同期処理へ差し替えられます.

path_providerの追加

JSON変換のdart:convertとファイル操作のdart:ioはDart標準ライブラリです.保存先には,OSごとの適切なアプリケーションサポートディレクトリを取得できるFlutter公式のpath_providerを使います.

$ flutter pub add path_provider

LinuxではApplication IDをもとにアプリ専用ディレクトリが決まります.生成時のcom.example.fluttergardenは学習用の仮IDなので,必要に応じてlinux/CMakeLists.txtの値をプロジェクト固有のIDへ変更します.

この記事では,学習用のIDとして次の値を使います.

set(APPLICATION_ID "dev.fluttergarden.app")

実際に配布する場合は,所有するドメインを逆順にしたIDなど,他のアプリと衝突しない値を使います.Application IDを変更すると保存先も変わるため,運用開始後の変更にはデータ移行が必要です.

TodoとJSONの相互変換

jsonEncode()がそのまま変換できるのは,文字列,数値,真偽値,null,List,Mapなどです.TodoにはMapとの相互変換を追加します.

factory Todo.fromJson(Map<String, Object?> json) {
  final id = json['id'];
  final title = json['title'];
  final completed = json['completed'];
  if (id is! int || title is! String || completed is! bool) {
    throw const FormatException('Invalid Todo JSON');
  }

  return Todo(id: id, title: title, completed: completed);
}

Map<String, Object?> toJson() {
  return {'id': id, 'title': title, 'completed': completed};
}

factoryコンストラクタは,必ず新しいインスタンスを直接作る通常のコンストラクタと異なり,変換やキャッシュなどの処理を行ってインスタンスを返せます.今回はJSONの型を検証してからTodoを作ります.

RepositoryでJSONファイルを読み書きする

JsonTodoRepositoryは,List<Todo>とJSON文字列の相互変換に加え,todos.jsonの読み書きを担当します.getApplicationSupportDirectory()で保存先を取得し,初回起動時にファイルがなければ空のListを返します.

lib/features/todo/json_todo_repository.dart
class JsonTodoRepository implements TodoRepository {
  JsonTodoRepository({Future<Directory> Function()? directoryProvider})
      : _directoryProvider =
            directoryProvider ?? getApplicationSupportDirectory;

  static const String fileName = 'todos.json';

  final Future<Directory> Function() _directoryProvider;

  Future<File> _getFile() async {
    final directory = await _directoryProvider();
    await directory.create(recursive: true);
    return File('${directory.path}${Platform.pathSeparator}$fileName');
  }

  @override
  Future<List<Todo>> loadTodos() async {
    final file = await _getFile();
    if (!await file.exists()) {
      return [];
    }

    final contents = await file.readAsString();
    final json = jsonDecode(contents);
    if (json is! List<Object?>) {
      throw const FormatException('Todo JSON must be a list');
    }

    return json.map((item) {
      if (item is! Map<String, Object?>) {
        throw const FormatException('Invalid Todo JSON item');
      }
      return Todo.fromJson(item);
    }).toList(growable: false);
  }

  @override
  Future<void> saveTodos(List<Todo> todos) async {
    final json = todos.map((todo) => todo.toJson()).toList(growable: false);
    final file = await _getFile();
    await file.writeAsString(jsonEncode(json), flush: true);
  }
}

Platform.pathSeparatorを使うことで,OSごとのパス区切り文字に対応します.今回は小規模なファイル保存のためRepositoryにまとめますが,外部アクセスが複雑になる場合はRepositoryの下にServiceを分離する構成も検討できます.

RepositoryをViewModelへ注入する

TodoViewModelは,コンストラクタでTodoRepositoryを受け取ります.

class TodoViewModel extends ChangeNotifier {
  TodoViewModel(this._repository);

  final TodoRepository _repository;
}

ViewModelは抽象にだけ依存し,JsonTodoRepositoryやファイルの存在を知りません.具体的な実装はProviderが作成し,ViewModelへ注入します.FlutterGardenAppの引数を省略した通常実行では,JsonTodoRepositoryを使用します.

Provider<TodoRepository>(
  create: (context) => todoRepository ?? JsonTodoRepository(),
),
ChangeNotifierProvider(
  create: (context) =>
      TodoViewModel(context.read<TodoRepository>())..loadTodos(),
),

ここでのread()は,ViewModelを作成するときにRepositoryを1回取得するために使います.Repositoryの変更でWidgetを再ビルドする必要はないため,watch()は使いません.

..loadTodos()..はDartのカスケード記法です.生成したTodoViewModelloadTodos()を呼び出しつつ,式全体の値としてはそのTodoViewModel自身を返します.

FlutterGardenAppは,テスト用のRepositoryをコンストラクタで受け取れるようにします.Widget TestではファイルへアクセスしないInMemoryTodoRepositoryを渡すことで,テストごとに同じ初期状態を用意できます.これは,依存する実装を外部から渡す単純な依存性注入です.

FlutterGardenApp(
  todoRepository: InMemoryTodoRepository(),
)

asyncとawaitで読み込む

Future<void>は,処理が将来完了することを表します.await中は処理の続きを一時中断してイベントループへ制御を戻すため,UI処理をブロックしません.

読み込み失敗と保存失敗ではViewに表示する内容が異なるため,エラーの種類をenumで定義します.ViewModelは表示文言ではなく,アプリの状態だけを保持します.

enum TodoError { loadFailed, saveFailed }
Future<void> loadTodos() async {
  _isLoading = true;
  _error = null;
  notifyListeners();

  try {
    final todos = await _repository.loadTodos();
    _todos
      ..clear()
      ..addAll(todos);
    _nextId = _todos.fold(
      0,
      (nextId, todo) => todo.id >= nextId ? todo.id + 1 : nextId,
    );
  } on Object {
    _error = TodoError.loadFailed;
  } finally {
    _isLoading = false;
    notifyListeners();
  }
}

処理開始時にisLoadingtrueにし,ViewにはCircularProgressIndicatorを表示します.成功時は読み込んだデータへ置き換え,次に使うIDも読み込み結果から決めます.失敗時はエラー状態をloadFailedにします.finallyは成功と失敗のどちらでも実行されるため,ここで読み込み状態を終了します.

追加・完了切り替え・削除も非同期メソッドに変更します.まずメモリ上の状態を更新して画面へ通知し,そのあとRepositoryへ保存するため,UIは保存完了を待たずに更新されます.保存に失敗した場合は現在の状態を画面に残し,エラー状態をsaveFailedにします.

Future<bool> addTodo(String title) async {
  final trimmedTitle = title.trim();
  if (trimmedTitle.isEmpty) {
    return false;
  }

  _todos.add(Todo(id: _nextId++, title: trimmedTitle));
  notifyListeners();
  await _saveTodos();
  return true;
}

Viewではエラーの種類を表示文言へ変換します.これにより,ViewModelが特定のUI文言を持たずに済みます.

final errorMessage = switch (viewModel.error) {
  TodoError.loadFailed => 'Failed to load ToDos',
  TodoError.saveFailed => 'Failed to save ToDos',
  null => null,
};

Viewでは完了をawaitしたあと,mountedStateがまだWidgetツリーに存在することを確認してからControllerを操作します.待機中に画面が破棄される可能性があるためです.

final added = await viewModel.addTodo(_textController.text);
if (added && mounted) {
  _textController.clear();
}

このように先にUIを更新する方式は楽観的更新と呼ばれます.操作感はよくなりますが,保存失敗時に元へ戻すのか,再試行するのかといった方針が必要です.今回は学習用の最小構成として,自動的なロールバックや再試行は行わず,画面の値を残してエラーを表示します.

保存処理を直列化する

短時間に複数の操作を行うと,先に開始した古い保存が後から完了し,新しいデータを上書きする可能性があります.そこで,ファイル保存の責務を持つJsonTodoRepositoryが,前の保存の完了後に次の保存を開始します.ViewModelは保存順を意識せず,単に現在のListのスナップショットを渡します.

await _repository.saveTodos(List.of(_todos));

Repositoryは受け取ったListをすぐJSON文字列へ変換し,その不変な文字列を保存チェーンへ追加します.

final contents = jsonEncode(json);
_pendingSave = _pendingSave.then(
  (_) => _write(contents),
  onError: (_) => _write(contents),
);
return _pendingSave;

List.of()は保存開始時点のListを浅くコピーします.中のTodoは不変なため共有し,Listの要素構成だけを固定します.onError側にも次の保存を書くことで,前回の保存に失敗した後も保存チェーンを継続します.

読み込み同士はデータを変更しないため,直列化する必要はありません.ただし,読み込みと保存が重なると途中のファイルを読む可能性があるため,loadTodos()は進行中の保存完了を待ってから読み込みます.また,Viewは初期読み込み中の追加操作を無効にし,読み込み結果で直後の操作が上書きされることを防ぎます.

Repositoryと非同期処理を含むコード全体
lib/features/todo/json_todo_repository.dart
import 'dart:convert';
import 'dart:io';

import 'package:fluttergarden/features/todo/todo.dart';
import 'package:fluttergarden/features/todo/todo_repository.dart';
import 'package:path_provider/path_provider.dart';

class JsonTodoRepository implements TodoRepository {
  JsonTodoRepository({Future<Directory> Function()? directoryProvider})
    : _directoryProvider = directoryProvider ?? getApplicationSupportDirectory;

  static const String fileName = 'todos.json';

  final Future<Directory> Function() _directoryProvider;
  Future<void> _pendingSave = Future.value();

  Future<File> _getFile() async {
    final directory = await _directoryProvider();
    await directory.create(recursive: true);
    return File('${directory.path}${Platform.pathSeparator}$fileName');
  }

  @override
  Future<List<Todo>> loadTodos() async {
    try {
      await _pendingSave;
    } on Object {
      // A previous save error must not prevent reloading from the file.
    }

    final file = await _getFile();
    if (!await file.exists()) {
      return [];
    }

    final contents = await file.readAsString();
    final json = jsonDecode(contents);
    if (json is! List<Object?>) {
      throw const FormatException('Todo JSON must be a list');
    }

    return json
        .map((item) {
          if (item is! Map<String, Object?>) {
            throw const FormatException('Invalid Todo JSON item');
          }
          return Todo.fromJson(item);
        })
        .toList(growable: false);
  }

  @override
  Future<void> saveTodos(List<Todo> todos) {
    final json = todos.map((todo) => todo.toJson()).toList(growable: false);
    final contents = jsonEncode(json);
    _pendingSave = _pendingSave.then(
      (_) => _write(contents),
      onError: (_) => _write(contents),
    );
    return _pendingSave;
  }

  Future<void> _write(String contents) async {
    final file = await _getFile();
    await file.writeAsString(contents, flush: true);
  }
}
lib/features/todo/todo_view_model.dart
import 'dart:collection';

import 'package:flutter/foundation.dart';
import 'package:fluttergarden/features/todo/todo.dart';
import 'package:fluttergarden/features/todo/todo_repository.dart';

enum TodoError { loadFailed, saveFailed }

class TodoViewModel extends ChangeNotifier {
  TodoViewModel(this._repository);

  final TodoRepository _repository;
  final List<Todo> _todos = [];
  late final List<Todo> _readonlyTodos = UnmodifiableListView(_todos);

  int _nextId = 0;
  bool _showOnlyIncomplete = false;
  bool _isLoading = false;
  TodoError? _error;

  List<Todo> get todos => _readonlyTodos;
  bool get showOnlyIncomplete => _showOnlyIncomplete;
  bool get isLoading => _isLoading;
  TodoError? get error => _error;
  List<Todo> get visibleTodos => _showOnlyIncomplete
      ? _todos.where((todo) => !todo.completed).toList(growable: false)
      : _readonlyTodos;

  Future<void> loadTodos() async {
    _isLoading = true;
    _error = null;
    notifyListeners();

    try {
      final todos = await _repository.loadTodos();
      _todos
        ..clear()
        ..addAll(todos);
      _nextId = _todos.fold(
        0,
        (nextId, todo) => todo.id >= nextId ? todo.id + 1 : nextId,
      );
    } on Object {
      _error = TodoError.loadFailed;
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }

  Future<bool> addTodo(String title) async {
    final trimmedTitle = title.trim();
    if (trimmedTitle.isEmpty) {
      return false;
    }

    _todos.add(Todo(id: _nextId++, title: trimmedTitle));
    notifyListeners();
    await _saveTodos();
    return true;
  }

  Future<void> toggleTodo(int id) async {
    final index = _todos.indexWhere((todo) => todo.id == id);
    if (index == -1) {
      return;
    }

    final todo = _todos[index];
    _todos[index] = todo.copyWith(completed: !todo.completed);
    notifyListeners();
    await _saveTodos();
  }

  Future<void> removeTodo(int id) async {
    final index = _todos.indexWhere((todo) => todo.id == id);
    if (index == -1) {
      return;
    }

    _todos.removeAt(index);
    notifyListeners();
    await _saveTodos();
  }

  void setShowOnlyIncomplete(bool value) {
    if (_showOnlyIncomplete == value) {
      return;
    }

    _showOnlyIncomplete = value;
    notifyListeners();
  }

  Future<void> _saveTodos() async {
    final previousError = _error;
    try {
      await _repository.saveTodos(List.of(_todos));
      _error = null;
    } on Object {
      _error = TodoError.saveFailed;
    }
    if (_error != previousError) {
      notifyListeners();
    }
  }
}
lib/main.dart
import 'package:flutter/material.dart';
import 'package:fluttergarden/features/calculator/calculator_view_model.dart';
import 'package:fluttergarden/features/home/home_page.dart';
import 'package:fluttergarden/features/todo/json_todo_repository.dart';
import 'package:fluttergarden/features/todo/todo_repository.dart';
import 'package:fluttergarden/features/todo/todo_view_model.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(const FlutterGardenApp());
}

class FlutterGardenApp extends StatelessWidget {
  const FlutterGardenApp({super.key, this.todoRepository});

  final TodoRepository? todoRepository;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'fluttergarden',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
      ),
      home: MultiProvider(
        providers: [
          ChangeNotifierProvider(create: (context) => CalculatorViewModel()),
          Provider<TodoRepository>(
            create: (context) => todoRepository ?? JsonTodoRepository(),
          ),
          ChangeNotifierProvider(
            create: (context) =>
                TodoViewModel(context.read<TodoRepository>())..loadTodos(),
          ),
        ],
        child: const HomePage(),
      ),
    );
  }
}
lib/features/todo/todo_page.dart
import 'package:flutter/material.dart';
import 'package:fluttergarden/features/todo/todo.dart';
import 'package:fluttergarden/features/todo/todo_view_model.dart';
import 'package:provider/provider.dart';

class TodoPage extends StatefulWidget {
  const TodoPage({super.key});

  @override
  State<TodoPage> createState() => _TodoPageState();
}

class _TodoPageState extends State<TodoPage> {
  late final TextEditingController _textController;

  @override
  void initState() {
    super.initState();
    _textController = TextEditingController();
  }

  @override
  void dispose() {
    _textController.dispose();
    super.dispose();
  }

  Future<void> _addTodo(TodoViewModel viewModel) async {
    final added = await viewModel.addTodo(_textController.text);
    if (added && mounted) {
      _textController.clear();
    }
  }

  @override
  Widget build(BuildContext context) {
    final viewModel = context.watch<TodoViewModel>();
    final visibleTodos = viewModel.visibleTodos;
    final errorMessage = switch (viewModel.error) {
      TodoError.loadFailed => 'Failed to load ToDos',
      TodoError.saveFailed => 'Failed to save ToDos',
      null => null,
    };

    return Center(
      child: Padding(
        padding: const EdgeInsets.all(32),
        child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 640),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Row(
                children: [
                  Expanded(
                    child: TextField(
                      controller: _textController,
                      enabled: !viewModel.isLoading,
                      decoration: const InputDecoration(
                        border: OutlineInputBorder(),
                        labelText: 'New ToDo',
                      ),
                      onSubmitted: (_) => _addTodo(viewModel),
                    ),
                  ),
                  const SizedBox(width: 16),
                  FilledButton(
                    onPressed: viewModel.isLoading
                        ? null
                        : () => _addTodo(viewModel),
                    child: const Text('Add'),
                  ),
                ],
              ),
              const SizedBox(height: 16),
              SwitchListTile(
                contentPadding: EdgeInsets.zero,
                title: const Text('Show only incomplete'),
                value: viewModel.showOnlyIncomplete,
                onChanged: viewModel.setShowOnlyIncomplete,
              ),
              const SizedBox(height: 8),
              if (errorMessage != null)
                Text(
                  errorMessage,
                  style: TextStyle(color: Theme.of(context).colorScheme.error),
                ),
              Expanded(
                child: viewModel.isLoading
                    ? const Center(child: CircularProgressIndicator())
                    : visibleTodos.isEmpty
                    ? const Center(child: Text('No ToDos'))
                    : ListView.builder(
                        itemCount: visibleTodos.length,
                        itemBuilder: (context, index) {
                          final todo = visibleTodos[index];
                          return _TodoListItem(
                            key: ValueKey(todo.id),
                            todo: todo,
                            onToggle: () => viewModel.toggleTodo(todo.id),
                            onRemove: () => viewModel.removeTodo(todo.id),
                          );
                        },
                      ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _TodoListItem extends StatelessWidget {
  const _TodoListItem({
    super.key,
    required this.todo,
    required this.onToggle,
    required this.onRemove,
  });

  final Todo todo;
  final VoidCallback onToggle;
  final VoidCallback onRemove;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      leading: Checkbox(value: todo.completed, onChanged: (_) => onToggle()),
      title: Text(
        todo.title,
        style: TextStyle(
          decoration: todo.completed ? TextDecoration.lineThrough : null,
        ),
      ),
      trailing: IconButton(
        tooltip: 'Delete',
        onPressed: onRemove,
        icon: const Icon(Icons.delete_outline),
      ),
    );
  }
}

RepositoryのUnit Test

JsonTodoRepositoryのテストでは,OSのアプリ用ディレクトリではなく,テスト用の一時ディレクトリを注入します.実際のファイルを使って,JSONの往復変換と不正なJSONの検出を確認しても,ユーザーのデータに影響しません.

ViewModelのテストでは,高速で状態を制御しやすいInMemoryTodoRepositoryを引き続き使います.本番実装とテスト実装を差し替えられることが,Repositoryを抽象化する利点です.

また,必ず例外を投げるテスト用Repositoryを渡すことで,読み込みや保存の失敗も再現できます.TodoErrorを直接比較するため,表示文言に依存せずエラー状態を検証できます.

class _FailingTodoRepository implements TodoRepository {
  @override
  Future<List<Todo>> loadTodos() {
    throw Exception('load failed');
  }

  @override
  Future<void> saveTodos(List<Todo> todos) async {}
}

expect(viewModel.error, TodoError.loadFailed);

Repositoryへ永続化の責務を分けたことで,ViewModelとViewにファイルやJSONの知識を持ち込まず,ToDoを永続化できました.

ToDoの編集画面

一覧画面だけで完結していたToDoアプリに,タイトルを変更する編集画面を追加します.ここでは,FlutterのNavigatorを使った画面遷移と,画面間での値の受け渡しを扱います.

PageViewとNavigatorの役割

これまでのCalculatorPageTodoPageは,HomePageが持つPageViewの子です.同じ画面内で表示する内容を切り替えており,HomePageAppBarNavigationBarは共通して表示されます.

一方,TodoEditPagePageViewの子には追加しません.MaterialAppが用意するNavigatorに,独立したRouteとして追加します.

MaterialApp
└─ Navigator
   ├─ HomePage
   │  ├─ AppBar
   │  ├─ PageView
   │  │  ├─ CalculatorPage
   │  │  └─ TodoPage
   │  └─ NavigationBar
   └─ TodoEditPage
      ├─ AppBar
      └─ 編集フォーム

NavigatorはRouteをスタックとして管理します.push()するとTodoEditPageHomePage全体の上へ積まれ,編集画面自身のScaffoldAppBarが表示されます.背後のHomePageは破棄されないため,pop()して戻ったときも,ToDoページを選択していた状態は維持されます.

戻れるRouteがスタックにあると,AppBarは戻るボタンを自動的に表示します.そのため,TodoEditPage側で戻るボタンを実装する必要はありません.

push前: [HomePage]
push後: [HomePage, TodoEditPage]
pop後:  [HomePage]

このアプリでは,PageViewを同一画面内の主要機能の切り替えに使い,Navigatorを一覧から編集画面へ進むような階層的な画面遷移に使っています.

編集画面へTodoを渡す

一覧のListTileがクリックされたら,Navigator.push()で編集画面を表示します.MaterialPageRoute<String>の型引数は,遷移先から戻ってくる値がStringであることを表します.

final title = await Navigator.of(context).push<String>(
  MaterialPageRoute(builder: (context) => TodoEditPage(todo: todo)),
);

編集対象のTodoは,TodoEditPageのコンストラクタへ渡します.編集画面は受け取ったタイトルをTextEditingControllerの初期値に設定します.

class TodoEditPage extends StatefulWidget {
  const TodoEditPage({super.key, required this.todo});

  final Todo todo;
}

void initState() {
  super.initState();
  _textController = TextEditingController(text: widget.todo.title);
}

Stateから対応するStatefulWidgetのプロパティを参照するときは,widget.todoのようにwidgetを経由します.

popで編集結果を返す

保存ボタンでは,新しいタイトルを引数にしてNavigator.pop()を呼び出します.これにより,編集画面を閉じながら一覧画面へタイトルを返せます.

void _save() {
  final title = _textController.text.trim();
  if (title.isEmpty) {
    return;
  }
  Navigator.of(context).pop(title);
}

一覧画面では,push()が返すFutureをawaitしています.編集画面でpop(title)を呼ぶとこの待機が終わり,titleに編集後の文字列が入ります.戻るボタンなどで保存せずに画面を閉じた場合,結果はnullになります.

一覧画面では,結果がある場合だけViewModelを更新します.await中にWidgetが破棄される可能性があるため,更新前にmountedも確認します.

if (title != null && mounted) {
  await viewModel.updateTodoTitle(todo.id, title);
}

編集画面は画面遷移と入力だけを担当し,Listの更新やJSON保存は行いません.実データの変更は,これまでと同じくTodoViewModelへ集約します.

final index = _todos.indexWhere((todo) => todo.id == id);
final todo = _todos[index];
_todos[index] = todo.copyWith(title: trimmedTitle);
notifyListeners();
await _saveTodos();

ここでも元のTodoは書き換えず,copyWith()で新しいTodoを作り,List内の要素を置き換えています.

編集画面を含むコード全体
lib/features/todo/todo_edit_page.dart
import 'package:flutter/material.dart';
import 'package:fluttergarden/features/todo/todo.dart';

class TodoEditPage extends StatefulWidget {
  const TodoEditPage({super.key, required this.todo});

  final Todo todo;

  @override
  State<TodoEditPage> createState() => _TodoEditPageState();
}

class _TodoEditPageState extends State<TodoEditPage> {
  late final TextEditingController _textController;

  @override
  void initState() {
    super.initState();
    _textController = TextEditingController(text: widget.todo.title);
  }

  @override
  void dispose() {
    _textController.dispose();
    super.dispose();
  }

  void _save() {
    final title = _textController.text.trim();
    if (title.isEmpty) {
      return;
    }
    Navigator.of(context).pop(title);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Edit ToDo')),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(32),
          child: ConstrainedBox(
            constraints: const BoxConstraints(maxWidth: 640),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                TextField(
                  controller: _textController,
                  autofocus: true,
                  decoration: const InputDecoration(
                    border: OutlineInputBorder(),
                    labelText: 'Title',
                  ),
                  onSubmitted: (_) => _save(),
                ),
                const SizedBox(height: 16),
                Align(
                  alignment: Alignment.centerRight,
                  child: FilledButton(
                    onPressed: _save,
                    child: const Text('Save'),
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
lib/features/todo/todo_page.dart
import 'package:flutter/material.dart';
import 'package:fluttergarden/features/todo/todo.dart';
import 'package:fluttergarden/features/todo/todo_edit_page.dart';
import 'package:fluttergarden/features/todo/todo_view_model.dart';
import 'package:provider/provider.dart';

class TodoPage extends StatefulWidget {
  const TodoPage({super.key});

  @override
  State<TodoPage> createState() => _TodoPageState();
}

class _TodoPageState extends State<TodoPage> {
  late final TextEditingController _textController;

  @override
  void initState() {
    super.initState();
    _textController = TextEditingController();
  }

  @override
  void dispose() {
    _textController.dispose();
    super.dispose();
  }

  Future<void> _addTodo(TodoViewModel viewModel) async {
    final added = await viewModel.addTodo(_textController.text);
    if (added && mounted) {
      _textController.clear();
    }
  }

  Future<void> _editTodo(TodoViewModel viewModel, Todo todo) async {
    final title = await Navigator.of(context).push<String>(
      MaterialPageRoute(builder: (context) => TodoEditPage(todo: todo)),
    );
    if (title != null && mounted) {
      await viewModel.updateTodoTitle(todo.id, title);
    }
  }

  @override
  Widget build(BuildContext context) {
    final viewModel = context.watch<TodoViewModel>();
    final visibleTodos = viewModel.visibleTodos;
    final errorMessage = switch (viewModel.error) {
      TodoError.loadFailed => 'Failed to load ToDos',
      TodoError.saveFailed => 'Failed to save ToDos',
      null => null,
    };

    return Center(
      child: Padding(
        padding: const EdgeInsets.all(32),
        child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 640),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Row(
                children: [
                  Expanded(
                    child: TextField(
                      controller: _textController,
                      enabled: !viewModel.isLoading,
                      decoration: const InputDecoration(
                        border: OutlineInputBorder(),
                        labelText: 'New ToDo',
                      ),
                      onSubmitted: (_) => _addTodo(viewModel),
                    ),
                  ),
                  const SizedBox(width: 16),
                  FilledButton(
                    onPressed: viewModel.isLoading
                        ? null
                        : () => _addTodo(viewModel),
                    child: const Text('Add'),
                  ),
                ],
              ),
              const SizedBox(height: 16),
              SwitchListTile(
                contentPadding: EdgeInsets.zero,
                title: const Text('Show only incomplete'),
                value: viewModel.showOnlyIncomplete,
                onChanged: viewModel.setShowOnlyIncomplete,
              ),
              const SizedBox(height: 8),
              if (errorMessage != null)
                Text(
                  errorMessage,
                  style: TextStyle(color: Theme.of(context).colorScheme.error),
                ),
              Expanded(
                child: viewModel.isLoading
                    ? const Center(child: CircularProgressIndicator())
                    : visibleTodos.isEmpty
                    ? const Center(child: Text('No ToDos'))
                    : ListView.builder(
                        itemCount: visibleTodos.length,
                        itemBuilder: (context, index) {
                          final todo = visibleTodos[index];
                          return _TodoListItem(
                            key: ValueKey(todo.id),
                            todo: todo,
                            onToggle: () => viewModel.toggleTodo(todo.id),
                            onTap: () => _editTodo(viewModel, todo),
                            onRemove: () => viewModel.removeTodo(todo.id),
                          );
                        },
                      ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _TodoListItem extends StatelessWidget {
  const _TodoListItem({
    super.key,
    required this.todo,
    required this.onToggle,
    required this.onTap,
    required this.onRemove,
  });

  final Todo todo;
  final VoidCallback onToggle;
  final VoidCallback onTap;
  final VoidCallback onRemove;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      onTap: onTap,
      leading: Checkbox(value: todo.completed, onChanged: (_) => onToggle()),
      title: Text(
        todo.title,
        style: TextStyle(
          decoration: todo.completed ? TextDecoration.lineThrough : null,
        ),
      ),
      trailing: IconButton(
        tooltip: 'Delete',
        onPressed: onRemove,
        icon: const Icon(Icons.delete_outline),
      ),
    );
  }
}
lib/features/todo/todo_view_model.dart
import 'dart:collection';

import 'package:flutter/foundation.dart';
import 'package:fluttergarden/features/todo/todo.dart';
import 'package:fluttergarden/features/todo/todo_repository.dart';

enum TodoError { loadFailed, saveFailed }

class TodoViewModel extends ChangeNotifier {
  TodoViewModel(this._repository);

  final TodoRepository _repository;
  final List<Todo> _todos = [];
  late final List<Todo> _readonlyTodos = UnmodifiableListView(_todos);

  int _nextId = 0;
  bool _showOnlyIncomplete = false;
  bool _isLoading = false;
  TodoError? _error;

  List<Todo> get todos => _readonlyTodos;
  bool get showOnlyIncomplete => _showOnlyIncomplete;
  bool get isLoading => _isLoading;
  TodoError? get error => _error;
  List<Todo> get visibleTodos => _showOnlyIncomplete
      ? _todos.where((todo) => !todo.completed).toList(growable: false)
      : _readonlyTodos;

  Future<void> loadTodos() async {
    _isLoading = true;
    _error = null;
    notifyListeners();

    try {
      final todos = await _repository.loadTodos();
      _todos
        ..clear()
        ..addAll(todos);
      _nextId = _todos.fold(
        0,
        (nextId, todo) => todo.id >= nextId ? todo.id + 1 : nextId,
      );
    } on Object {
      _error = TodoError.loadFailed;
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }

  Future<bool> addTodo(String title) async {
    final trimmedTitle = title.trim();
    if (trimmedTitle.isEmpty) {
      return false;
    }

    _todos.add(Todo(id: _nextId++, title: trimmedTitle));
    notifyListeners();
    await _saveTodos();
    return true;
  }

  Future<void> toggleTodo(int id) async {
    final index = _todos.indexWhere((todo) => todo.id == id);
    if (index == -1) {
      return;
    }

    final todo = _todos[index];
    _todos[index] = todo.copyWith(completed: !todo.completed);
    notifyListeners();
    await _saveTodos();
  }

  Future<bool> updateTodoTitle(int id, String title) async {
    final trimmedTitle = title.trim();
    if (trimmedTitle.isEmpty) {
      return false;
    }

    final index = _todos.indexWhere((todo) => todo.id == id);
    if (index == -1) {
      return false;
    }

    final todo = _todos[index];
    if (todo.title == trimmedTitle) {
      return true;
    }

    _todos[index] = todo.copyWith(title: trimmedTitle);
    notifyListeners();
    await _saveTodos();
    return true;
  }

  Future<void> removeTodo(int id) async {
    final index = _todos.indexWhere((todo) => todo.id == id);
    if (index == -1) {
      return;
    }

    _todos.removeAt(index);
    notifyListeners();
    await _saveTodos();
  }

  void setShowOnlyIncomplete(bool value) {
    if (_showOnlyIncomplete == value) {
      return;
    }

    _showOnlyIncomplete = value;
    notifyListeners();
  }

  Future<void> _saveTodos() async {
    final previousError = _error;
    try {
      await _repository.saveTodos(List.of(_todos));
      _error = null;
    } on Object {
      _error = TodoError.saveFailed;
    }
    if (_error != previousError) {
      notifyListeners();
    }
  }
}

ToDoをクリックすると,現在のタイトルが入力された編集画面へ遷移します.

既存のタイトルを表示したToDo編集画面

編集機能についても,ViewModelによるタイトル更新をUnit Testで,編集画面から一覧へ戻る一連の操作をWidget Testで確認します.

編集機能を含むテストコード全体
test/features/todo/todo_view_model_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:fluttergarden/features/todo/in_memory_todo_repository.dart';
import 'package:fluttergarden/features/todo/todo.dart';
import 'package:fluttergarden/features/todo/todo_repository.dart';
import 'package:fluttergarden/features/todo/todo_view_model.dart';

void main() {
  late TodoViewModel viewModel;

  setUp(() {
    viewModel = TodoViewModel(InMemoryTodoRepository());
  });

  test('初期状態でToDoが空', () {
    expect(viewModel.todos, isEmpty);
    expect(viewModel.showOnlyIncomplete, isFalse);
  });

  test('ToDoを追加できる', () async {
    expect(await viewModel.addTodo('  Buy milk  '), isTrue);

    expect(viewModel.todos, hasLength(1));
    expect(viewModel.todos.single.title, 'Buy milk');
    expect(viewModel.todos.single.completed, isFalse);
  });

  test('空白だけのToDoは追加しない', () async {
    expect(await viewModel.addTodo('   '), isFalse);
    expect(viewModel.todos, isEmpty);
  });

  test('ToDoの完了状態を切り替えられる', () async {
    await viewModel.addTodo('Buy milk');
    final originalTodo = viewModel.todos.single;

    await viewModel.toggleTodo(originalTodo.id);

    expect(viewModel.todos.single.completed, isTrue);
    expect(identical(viewModel.todos.single, originalTodo), isFalse);
  });

  test('ToDoのタイトルを変更できる', () async {
    await viewModel.addTodo('Buy milk');
    final originalTodo = viewModel.todos.single;

    expect(
      await viewModel.updateTodoTitle(originalTodo.id, '  Buy coffee  '),
      isTrue,
    );

    expect(viewModel.todos.single.title, 'Buy coffee');
    expect(identical(viewModel.todos.single, originalTodo), isFalse);
  });

  test('ToDoのタイトルを空白だけには変更しない', () async {
    await viewModel.addTodo('Buy milk');
    final id = viewModel.todos.single.id;

    expect(await viewModel.updateTodoTitle(id, '   '), isFalse);
    expect(viewModel.todos.single.title, 'Buy milk');
  });

  test('ToDoを削除できる', () async {
    await viewModel.addTodo('Buy milk');
    final id = viewModel.todos.single.id;

    await viewModel.removeTodo(id);

    expect(viewModel.todos, isEmpty);
  });

  test('未完了のToDoだけを取得できる', () async {
    await viewModel.addTodo('Completed');
    await viewModel.addTodo('Incomplete');
    await viewModel.toggleTodo(viewModel.todos.first.id);

    viewModel.setShowOnlyIncomplete(true);

    expect(viewModel.visibleTodos.map((todo) => todo.title), ['Incomplete']);
    expect(viewModel.todos, hasLength(2));
  });

  test('公開されたListは変更できない', () {
    expect(
      () => viewModel.todos.add(const Todo(id: 0, title: 'Buy milk')),
      throwsUnsupportedError,
    );
  });

  test('RepositoryからToDoを読み込める', () async {
    viewModel = TodoViewModel(
      InMemoryTodoRepository(
        initialTodos: const [Todo(id: 10, title: 'Loaded')],
      ),
    );

    await viewModel.loadTodos();
    await viewModel.addTodo('Next');

    expect(viewModel.todos.map((todo) => todo.title), ['Loaded', 'Next']);
    expect(viewModel.todos.last.id, 11);
    expect(viewModel.isLoading, isFalse);
    expect(viewModel.error, isNull);
  });

  test('Repositoryの読み込み失敗を通知できる', () async {
    viewModel = TodoViewModel(_FailingTodoRepository());

    await viewModel.loadTodos();

    expect(viewModel.isLoading, isFalse);
    expect(viewModel.error, TodoError.loadFailed);
  });

  test('Repositoryの保存失敗を通知できる', () async {
    viewModel = TodoViewModel(_FailingSaveTodoRepository());

    await viewModel.addTodo('Buy milk');

    expect(viewModel.error, TodoError.saveFailed);
  });
}

class _FailingTodoRepository implements TodoRepository {
  @override
  Future<List<Todo>> loadTodos() {
    throw Exception('load failed');
  }

  @override
  Future<void> saveTodos(List<Todo> todos) async {}
}

class _FailingSaveTodoRepository implements TodoRepository {
  @override
  Future<List<Todo>> loadTodos() async => [];

  @override
  Future<void> saveTodos(List<Todo> todos) {
    throw Exception('save failed');
  }
}
test/widget_test.dart
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fluttergarden/features/todo/in_memory_todo_repository.dart';

import 'package:fluttergarden/main.dart';

void main() {
  FlutterGardenApp createTestApp() {
    return FlutterGardenApp(todoRepository: InMemoryTodoRepository());
  }

  testWidgets('面積を計算できる', (WidgetTester tester) async {
    await tester.pumpWidget(createTestApp());

    expect(find.text('Height'), findsOneWidget);
    expect(find.text('Width'), findsOneWidget);
    expect(find.text('Area'), findsOneWidget);

    final sliders = find.byType(Slider);
    final heightSlider = tester.widget<Slider>(sliders.at(0));
    final widthSlider = tester.widget<Slider>(sliders.at(1));

    heightSlider.onChanged!(10);
    await tester.pump();
    widthSlider.onChanged!(20);
    await tester.pump();

    expect(find.text('200'), findsNothing);

    await tester.tap(find.text('Calculate'));
    await tester.pump();

    expect(find.text('200'), findsOneWidget);
  });

  testWidgets('連続計算で入力時に面積を更新できる', (WidgetTester tester) async {
    await tester.pumpWidget(createTestApp());

    await tester.tap(find.byType(Switch));
    await tester.pump();

    final sliders = find.byType(Slider);
    final heightSlider = tester.widget<Slider>(sliders.at(0));
    final widthSlider = tester.widget<Slider>(sliders.at(1));

    heightSlider.onChanged!(30);
    await tester.pump();
    widthSlider.onChanged!(20);
    await tester.pump();

    expect(find.text('600'), findsOneWidget);
  });

  testWidgets('ナビゲーションでToDo画面へ移動できる', (WidgetTester tester) async {
    await tester.pumpWidget(createTestApp());

    await tester.tap(find.text('ToDo'));
    await tester.pumpAndSettle();

    expect(find.text('New ToDo'), findsOneWidget);
  });

  testWidgets('マウスドラッグでToDo画面へ移動できる', (WidgetTester tester) async {
    await tester.pumpWidget(createTestApp());

    final gesture = await tester.startGesture(
      tester.getCenter(find.byType(PageView)),
      kind: PointerDeviceKind.mouse,
    );
    await gesture.moveBy(const Offset(-500, 0));
    await gesture.up();
    await tester.pumpAndSettle();

    expect(find.text('New ToDo'), findsOneWidget);
  });

  testWidgets('ToDoを追加して完了と削除を操作できる', (WidgetTester tester) async {
    await tester.pumpWidget(createTestApp());
    await tester.tap(find.text('ToDo'));
    await tester.pumpAndSettle();

    await tester.enterText(find.byType(TextField), 'Buy milk');
    await tester.tap(find.text('Add'));
    await tester.pump();

    expect(find.text('Buy milk'), findsOneWidget);
    expect(find.text('No ToDos'), findsNothing);

    await tester.tap(find.byType(Checkbox));
    await tester.pump();
    final todoText = tester.widget<Text>(find.text('Buy milk'));
    expect(todoText.style?.decoration, TextDecoration.lineThrough);

    await tester.tap(find.byTooltip('Delete'));
    await tester.pump();
    expect(find.text('Buy milk'), findsNothing);
    expect(find.text('No ToDos'), findsOneWidget);
  });

  testWidgets('ToDoの編集画面でタイトルを変更できる', (WidgetTester tester) async {
    await tester.pumpWidget(createTestApp());
    await tester.tap(find.text('ToDo'));
    await tester.pumpAndSettle();

    await tester.enterText(find.byType(TextField), 'Buy milk');
    await tester.tap(find.text('Add'));
    await tester.pump();
    await tester.tap(find.text('Buy milk'));
    await tester.pumpAndSettle();

    expect(find.text('Edit ToDo'), findsOneWidget);
    await tester.enterText(find.byType(TextField), 'Buy coffee');
    await tester.tap(find.text('Save'));
    await tester.pumpAndSettle();

    expect(find.text('Buy milk'), findsNothing);
    expect(find.text('Buy coffee'), findsOneWidget);
  });

  testWidgets('画面を移動しても面積計算の状態を保持する', (WidgetTester tester) async {
    await tester.pumpWidget(createTestApp());

    final sliders = find.byType(Slider);
    tester.widget<Slider>(sliders.at(0)).onChanged!(10);
    await tester.pump();
    tester.widget<Slider>(sliders.at(1)).onChanged!(20);
    await tester.pump();
    await tester.tap(find.text('Calculate'));
    await tester.pump();

    await tester.tap(find.text('ToDo'));
    await tester.pumpAndSettle();
    await tester.tap(find.text('Calculator'));
    await tester.pumpAndSettle();

    expect(find.text('200'), findsOneWidget);
  });
}

おわりに

本記事では,FlutterによるLinuxデスクトップアプリの実装を通じて,状態に応じて画面を組み立てる基本を学び,ChangeNotifierとproviderを使った状態管理,MVVMを意識したViewとViewModelの分離までを見てきました.さらに,PageViewによる2画面構成とToDoアプリへ発展させ,不変なModel,RepositoryによるJSON永続化,非同期処理,Navigatorを使った画面遷移,Unit TestとWidget Testを段階的に追加しました.

シンプルなUIであれば,ViewModelやRepositoryへ責務を分ける構成は少し回りくどく感じるかもしれません.しかし,画面や状態,外部アクセスが増えてくると,それぞれを独立して変更・テストできる利点が大きくなります.FlutterではWidgetを状態から再構築する考え方が中心となるため,状態をどこで管理し,どの範囲へ通知するかを整理することが,保守しやすいアプリを作るうえで重要になります.

MVVMやproviderはFlutterにおける唯一の正解ではありませんが,本記事で扱った責務分離,不変なデータ,依存性の注入といった考え方は,ほかの状態管理手法でも活用できます.ここから削除確認やUndo,別の永続化方法などを足していくのも面白そうです.本記事が,Flutterでアプリを作るときの取っかかりになればうれしいです.

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?