Open28

【不定期更新】Android Sunflower を Flutter で実装してみる

i53i53

タブのあるホーム画面を追加しよう:

import 'package:flutter/material.dart';
import 'package:flutter_sunflower/home_screen.dart';

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

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Sunflower',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const HomeScreen(),
    );
  }
}

DefaultTabControllerTabBarTabBarView Widget でマテリアルデザインのタブコンポーネントに準拠したタブ画面を簡単に構築することができる:

import 'package:flutter/material.dart';

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

  
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        appBar: AppBar(
          bottom: const TabBar(
            tabs: [
              Tab(text: 'My garden'),
              Tab(text: 'Plant list'),
            ],
          ),
          title: const Text('Sunflower'),
        ),
        body: TabBarView(
          children: [
            Container(),
            Container(),
          ],
        ),
      ),
    );
  }
}

https://docs.flutter.dev/cookbook/design/tabs
https://m3.material.io/components/tabs/overview

i53i53

タブのアイコン素材を準備する:

Android の Sunflower サンプルでは、アイコン素材はベクター型ドラーアブル というフォーマットで保存されている:

ic_my_garden_active.xml ic_plant_list_active.xml
リソースURL リソースURL

VectorDrawable のままでもレンダリングする方法もあるにはある...が、面倒なのでSVGに変換しよう。

<!-- ic_my_garden_active.svg -->
<svg id="vector" xmlns="http://www.w3.org/2000/svg" width="24" height="28" viewBox="0 0 18 21"><path fill="?attr/colorOnPrimary" d="M9,21C13.97,21 18,16.97 18,12C13.03,12 9,16.03 9,21ZM2.6,9.25C2.6,10.63 3.72,11.75 5.1,11.75C5.63,11.75 6.11,11.59 6.52,11.31L6.5,11.5C6.5,12.88 7.62,14 9,14C10.38,14 11.5,12.88 11.5,11.5L11.48,11.31C11.88,11.59 12.37,11.75 12.9,11.75C14.28,11.75 15.4,10.63 15.4,9.25C15.4,8.25 14.81,7.4 13.97,7C14.81,6.6 15.4,5.75 15.4,4.75C15.4,3.37 14.28,2.25 12.9,2.25C12.37,2.25 11.89,2.41 11.48,2.69L11.5,2.5C11.5,1.12 10.38,0 9,0C7.62,0 6.5,1.12 6.5,2.5L6.52,2.69C6.12,2.41 5.63,2.25 5.1,2.25C3.72,2.25 2.6,3.37 2.6,4.75C2.6,5.75 3.19,6.6 4.03,7C3.19,7.4 2.6,8.25 2.6,9.25ZM9,4.5C10.38,4.5 11.5,5.62 11.5,7C11.5,8.38 10.38,9.5 9,9.5C7.62,9.5 6.5,8.38 6.5,7C6.5,5.62 7.62,4.5 9,4.5ZM0,12C0,16.97 4.03,21 9,21C9,16.03 4.97,12 0,12Z" fill-rule="nonzero" id="path_0"/></svg>
<!-- ic_plant_list_active.svg -->
<svg id="vector" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 20 24"><path fill="?attr/colorOnPrimary" d="M18.3246,20.6607C14.6597,24.2525 7.0157,24.716 2.7225,20.4289C3.6649,19.8496 4.6073,19.2703 6.2827,19.3861C6.911,18.6909 7.8534,18.2275 9.3194,18.4592C10.3665,11.9707 7.4346,9.1899 4.9215,8.6106C6.0733,9.3058 7.7487,10.4645 8.2723,13.3611C1.6754,13.9404 1.1518,10.001 0,6.4092C4.5026,5.3664 7.8534,6.2933 9.8429,10.001C10.3665,1.4269 15.6021,0.7317 20,0.5C19.267,4.3236 19.4764,10.4645 12.4607,11.3914C12.5654,8.7265 13.6126,6.2933 16.2304,4.0918C10.9948,5.714 10.5759,13.9404 11.5183,18.4592C12.6702,18.4592 13.4031,18.4592 14.1361,19.3861C16.1257,19.1544 17.801,20.0813 18.3246,20.6607Z" fill-rule="evenodd" id="path_0"/></svg>

VectorDrawable to SVG Converter というWebアプリが公開されているため活用させていただこう:
https://vd.floo.app/

i53i53

pubspec.yaml を編集して、リソースを参照できるようにする:

# The following section is specific to Flutter packages.
flutter:

  uses-material-design: true
  
  # これ
  assets:
    - assets/

  ...

プロジェクト直下に assets ディレクトリを作成して、アイコン素材のSVGファイルを追加しておこう

i53i53

SVGのレンダリングには flutter_svg というパッケージを利用する:
https://pub.dev/packages/flutter_svg


では、home_screen.dart を編集して、タブにアイコンを追加してみよう:

import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';

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

  
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        appBar: AppBar(
          bottom: TabBar(
            tabs: [
              Tab(
                text: 'My garden',
                icon: SvgPicture.asset('assets/ic_my_garden_active.svg'),
              ),
              Tab(
                text: 'Plant list',
                icon: SvgPicture.asset('assets/ic_plant_list_active.svg'),
              ),
            ],
          ),
          title: const Text('Sunflower'),
        ),
        body: TabBarView(
          children: [
            Container(),
            Container(),
          ],
        ),
      ),
    );
  }
}

アイコンが表示されるようになった:

Hidden comment
i53i53

SVGアイコンの色の指定について:

SvgPicture には color というパラメータがあるが、これは現在 deprecated になっている:

SvgPicture.asset(
  assetName,
  color: Colors.red, // deprecated
);

color の代わりに colorFilter を指定する。
色の変更だけなら BlendMode の srcIn で問題ない:

SvgPicture.asset(
  assetName,
  colorFilter: ColorFilter.mode(Colors.red, BlendMode.srcIn),
);

毎回生成するのは面倒なので Color に extension を追加しておくと便利:

extension ColorExt on Color {
  ColorFilter get colorFilter => ColorFilter.mode(this, BlendMode.srcIn);
}

SvgPicture.asset(
  assetName,
  colorFilter: Colors.red.colorFilter,
);
i53i53

まず、真っ先に思いつくかもしれないタブが切り替わった時に色を変えてみる安直なサンプルをこしらへてみる。先に言ってしまうと、これは不正解である:

import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';

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

  
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen>
    with SingleTickerProviderStateMixin {
  late TabController _tabController;
  var _index = 0;

  
  void initState() {
    super.initState();
    _tabController = TabController(vsync: this, length: 2);
    _tabController.addListener(() {
      setState(() {
        _index = _tabController.index;
      });
    });
  }

  
  void dispose() {
    _tabController.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;

    return Scaffold(
      appBar: AppBar(
        bottom: TabBar(
          controller: _tabController,
          tabs: [
            Tab(
              text: 'My garden',
              icon: SvgPicture.asset(
                'assets/ic_my_garden_active.svg',
                colorFilter: _index == 0
                    ? colorScheme.primary.colorFilter
                    : colorScheme.secondary.colorFilter,
              ),
            ),
            Tab(
              text: 'Plant list',
              icon: SvgPicture.asset(
                'assets/ic_plant_list_active.svg',
                colorFilter: _index == 1
                    ? colorScheme.primary.colorFilter
                    : colorScheme.secondary.colorFilter,
              ),
            ),
          ],
        ),
        title: const Text('Sunflower'),
      ),
      body: TabBarView(
        controller: _tabController,
        children: [
          Container(),
          Container(),
        ],
      ),
    );
  }
}

(普段 Flutter Hooks を使っているため、かなり久しぶりに StatefulWidget で書いた...)

i53i53

ちなみに、Flutter Hooks を使用すると、上記はもっと簡単に書くことができる:

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_svg/flutter_svg.dart';

class HomeScreen extends HookWidget {
  const HomeScreen({super.key});

  
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;

    final tabController = useTabController(initialLength: 2);
    final index = useState(tabController.index);

    useEffect(() {
      tabController.addListener(() {
        index.value = tabController.index;
      });
      return;
    }, [tabController]);

    return Scaffold(
      appBar: AppBar(
        bottom: TabBar(
          controller: tabController,
          tabs: [
            Tab(
              text: 'My garden',
              icon: SvgPicture.asset(
                'assets/ic_my_garden_active.svg',
                colorFilter: index.value == 0
                    ? colorScheme.primary.colorFilter
                    : colorScheme.secondary.colorFilter,
              ),
            ),
            Tab(
              text: 'Plant list',
              icon: SvgPicture.asset(
                'assets/ic_plant_list_active.svg',
                colorFilter: index.value == 1
                    ? colorScheme.primary.colorFilter
                    : colorScheme.secondary.colorFilter,
              ),
            ),
          ],
        ),
        title: const Text('Sunflower'),
      ),
      body: TabBarView(
        controller: tabController,
        children: [
          Container(),
          Container(),
        ],
      ),
    );
  }
}
i53i53

さて、なぜ上記が不正解なのかと言うと、タブをタップした時には即座に色が変わってくれるものの、ページをスライドした時の色はラベルと同期していないからである:

tabController の index が切り替わってから色を変えているようでは遅すぎるし、そもそもスクロールのoffsetに応じて色が変化するラベルの色に追従することはできない。

簡単な解説

TabControllerChangeNotifier を継承している。

そして、addListener で登録したクロージャが呼び出されるタイミングというのは、notifyListeners が呼び出された瞬間である。

試しに tabController 内の notifyListeners にブレークポイント(BP)を設置してデバッグしてみよう。
スワイプでページを切り替えてしばらくするとBPにヒットする:

コーススタックからさらに呼び出し元に遡っていく:

そして、ここにたどり着く:

この _handleScrollNotification は TabBarView の build メソッドで ScrollNotification の Listener に登録されている:

スクロール通知が ScrollEndNotification に切り替わったタイミング、すなわちユーザが指を離してスクロールをとめたところで、tabController の index がTabBarView内のPageViewの pageController.page (ページのindex) で更新される。

その後、タブのindexの更新によって、notifyListeners が呼び出されるのである。

TabBarView の実態は、PageView のスクロール状態を監視して、tabController の index を切り替えるだけなのだと言うことも分かる。

i53i53

さて、ラベルの色とアイコン画像の色を同期させるにはどうすればよいのか。

そもそも、Tab の icon プロパティには任意の Widget 渡せるのだが、Text や Icon Widget を渡した時にはアイコンの色がラベルの色に同期することがわかっている:

Tab(
    text: 'My garden',
    icon: Text('blaaaaaaaaa'), // 適当な Text Widget に置き換えてみる
),
Tab(
    text: 'Plant list',
    icon: Icon(Icons.add), // 適当な Icon Widget に置き換えてみる
),

察しが良い方は InheritedWidget で色のデータを伝搬しているのではないか、と思い至るかもしれない。

Icon Widget の中に潜っていくと答えがある:


Widget build(BuildContext context) {
  ...
  final IconThemeData iconTheme = IconTheme.of(context); // これ
  ...
  Color iconColor = color ?? iconTheme.color!;
  ...
}

color プロパティの指定のない Icon Widget は IconThemeData から、自身の色を参照している。


タブアニメーション時の色の更新については、tabs.dart の中に答えがある。

class _TabStyle extends AnimatedWidget {
  ...
  MaterialStateColor _resolveWithLabelColor(BuildContext context) {
    // タブアニメーションの進捗 (0.0 ~ 1.0)
    final Animation<double> animation = listenable as Animation<double>;

    // タブの選択状態に応じて lerp 関数で補間した色を返す
    return MaterialStateColor.resolveWith((Set<MaterialState> states) {
      if (states.contains(MaterialState.selected)) {
        return Color.lerp(selectedColor, unselectedColor, animation.value)!;
      }
      return Color.lerp(unselectedColor, selectedColor, animation.value)!;
    });
  }

  
  Widget build(BuildContext context) {
    ...
    final Color color = _resolveWithLabelColor(context).resolve(states);

    return DefaultTextStyle(
      style: textStyle.copyWith(color: color), // Text の color を更新
      child: IconTheme.merge(
        data: IconThemeData(
          size: 24.0,
          color: color, // IconThemeData の color を更新
        ),
        child: child,
      ),
    );
  }
}
i53i53

ということで、タブに応じてSVGアイコンの色を変更するサンプルは以下の通りである:

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

  
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        appBar: AppBar(
          bottom: const TabBar(
            tabs: [
              Tab(
                text: 'My garden',
                icon: _SvgIcon('assets/ic_my_garden_active.svg'),
              ),
              Tab(
                text: 'Plant list',
                icon: _SvgIcon('assets/ic_plant_list_active.svg'),
              ),
            ],
          ),
          title: const Text('Sunflower'),
        ),
        body: TabBarView(
          children: [
            Container(),
            Container(),
          ],
        ),
      ),
    );
  }
}
 
class _SvgIcon extends StatelessWidget {
  const _SvgIcon(this.assetName);

  final String assetName;

  
  Widget build(BuildContext context) {
    final iconTheme = IconTheme.of(context);
    return SvgPicture.asset(
      assetName,
      colorFilter: ColorFilter.mode(iconTheme.color!, BlendMode.srcIn),
    );
  }
}
Hidden comment
i53i53

ColorSchemeの変更

MaterialAppThemaData を設定することができる。

https://api.flutter.dev/flutter/material/ThemeData-class.html

flutter create 時点のテンプレートでは、ColorScheme は Colors.deepPurple をプライマリー・カラー とて生成されている:

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Sunflower',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), // これ
        useMaterial3: true,
      ),
      home: const HomeScreen(),
    );
  }
}

ColorScheme.fromSeed で、プライマリー・カラーを指定するだけで、Material 3 のカラーシステムに基づいた役割ごとの色のパレットを自動生成してくれる:
https://m3.material.io/styles/color/the-color-system/color-roles

Android Sunflower では Colors.kt 内にシード・カラーの情報が格納されている:

val seed = Color(0xFF256F00)


ColorScheme を変更してみよう:

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Sunflower',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF256F00)), // これ
        useMaterial3: true,
      ),
      home: const HomeScreen(),
    );
  }
}
Hidden comment
i53i53

Sunflower の GardenScreen.kt 内の EmptyGarden に相当するページを追加する:

GardenScreen EmptyGarden
@Composable
private fun EmptyGarden(onAddPlantClick: () -> Unit, modifier: Modifier = Modifier) {
    // Calls reportFullyDrawn when this composable is composed.
    ReportDrawn()

    Column(
        modifier,
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = stringResource(id = R.string.garden_empty),
            style = MaterialTheme.typography.headlineSmall
        )
        Button(
            shape = MaterialTheme.shapes.medium,
            onClick = onAddPlantClick
        ) {
            Text(
                text = stringResource(id = R.string.add_plant),
                style = MaterialTheme.typography.titleSmall
            )
        }
    }
}

Column コンテナでテキストとボタンを縦に並べるのは Compose と変わらない:

import 'package:flutter/material.dart';

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

  
  Widget build(BuildContext context) {
    return const _EmptyGarden();
  }
}

class _EmptyGarden extends StatelessWidget {
  const _EmptyGarden();

  
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text('Your garden is empty'),
        FilledButton(
          onPressed: () {},
          child: Text('Add plant'),
        ),
      ],
    );
  }
}
Compose Flutter
i53i53

次に、Themeを Android Sunflower と同等のものに更新する。

Android Sunflower のテーマは、Theme.kt に定義されている:

MaterialTheme(
    colorScheme = colorScheme,
    shapes = Shapes,
    typography = Typography,
    content = content
)

そして、「Your garden is empty」のText のスタイルに相当するものは、Type.kt に Typography で定義されている:

Text(
    text = stringResource(id = R.string.garden_empty),
    style = MaterialTheme.typography.headlineSmall // これ
)
val Typography = Typography(
    ...
    headlineSmall = TextStyle(
        fontWeight = FontWeight.Normal,
        fontSize = 30.sp
    ),
    ...
)

また、「Add plant」 ボタンの角丸については、Shapes.kt に定義されている:

Button(
    shape = MaterialTheme.shapes.medium, // これ
    onClick = onAddPlantClick
)
val Shapes = Shapes(
    ...
    medium = RoundedCornerShape(
        topStart = 0.dp,
        topEnd = 12.dp,
        bottomStart = 12.dp,
        bottomEnd = 0.dp
    )
)
i53i53

Flutter 側では上記に相当するものを ColorTheme と同様に、 MaterialApp の ThemeData で上書きする:

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Sunflower',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF256F00)),
        useMaterial3: true,
        // Text の Theme を更新する
        textTheme: const TextTheme(
          displaySmall: TextStyle(fontSize: 36, fontWeight: FontWeight.normal),
          headlineSmall: TextStyle(fontSize: 30, fontWeight: FontWeight.normal),
          labelSmall: TextStyle(fontSize: 13, fontWeight: FontWeight.normal),
          titleSmall: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
          titleMedium: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: 0.5),
          titleLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.normal),
        ),
        // FilledButton の Theme を更新する
        filledButtonTheme: const FilledButtonThemeData(
          style: ButtonStyle(
            shape: MaterialStatePropertyAll(
              RoundedRectangleBorder(
                borderRadius: BorderRadius.only(
                  topLeft: Radius.zero,
                  topRight: Radius.circular(12),
                  bottomLeft: Radius.circular(12),
                  bottomRight: Radius.zero,
                ),
              ),
            ),
          ),
        ),
      ),
      home: const HomeScreen(),
    );
  }
}

更新したThemeを適用するとそれらしくなった:

Compose Flutter
i53i53

次は Plant list タブのスクリーンを実装していく:

PlantListScreen.kt にGridの実装がある:

@Composable
fun PlantListScreen(
    plants: List<Plant>,
    modifier: Modifier = Modifier,
    onPlantClick: (Plant) -> Unit = {},
) {
    LazyVerticalGrid(
        columns = GridCells.Fixed(2),
        modifier = modifier.testTag("plant_list"),
        contentPadding = PaddingValues(
            horizontal = dimensionResource(id = R.dimen.card_side_margin),
            vertical = dimensionResource(id = R.dimen.header_margin)
        )
    ) {
        items(
            items = plants,
            key = { it.plantId }
        ) { plant ->
            PlantListItem(plant = plant) {
                onPlantClick(plant)
            }
        }
    }
}

また、PlantListItemView.kt にリストのセルの実装がある:

@Composable
fun ImageListItem(name: String, imageUrl: String, onClick: () -> Unit) {
    Card(
        onClick = onClick,
        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
        modifier = Modifier
            .padding(horizontal = dimensionResource(id = R.dimen.card_side_margin))
            .padding(bottom = dimensionResource(id = R.dimen.card_bottom_margin))
    ) {
        Column(Modifier.fillMaxWidth()) {
            SunflowerImage(
                model = imageUrl,
                contentDescription = stringResource(R.string.a11y_plant_item_image),
                Modifier
                    .fillMaxWidth()
                    .height(dimensionResource(id = R.dimen.plant_item_image_height)),
                contentScale = ContentScale.Crop
            )
            Text(
                text = name,
                textAlign = TextAlign.Center,
                maxLines = 1,
                style = MaterialTheme.typography.titleMedium,
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(vertical = dimensionResource(id = R.dimen.margin_normal))
                    .wrapContentWidth(Alignment.CenterHorizontally)
            )
        }
    }
}

assets/plants.json には各植物のデータある:

[
  {
    "plantId": "malus-pumila",
    "name": "Apple",
    "description": "An apple is a sweet, edible fruit produced by an apple tree (Malus pumila). (略),
    "growZoneNumber": 3,
    "wateringInterval": 30,
    "imageUrl": "https://upload.wikimedia.org/wikipedia/commons/5/55/Apple_orchard_in_Tasmania.jpg"
  },
  ...

これらの材料を元にFlutter側でも実装していこう。

i53i53

まずは 先の JSON を変換するためのデータクラスから実装していく:

{
    "plantId": "malus-pumila",
    "name": "Apple",
    "description": "An apple is a sweet, edible fruit produced by an apple tree (Malus pumila).,
    "growZoneNumber": 3,
    "wateringInterval": 30,
    "imageUrl": "https://upload.wikimedia.org/wikipedia/commons/5/55/Apple_orchard_in_Tasmania.jpg"
},

先に freezedjson_serializable を導入しておこう:

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  freezed_annotation:
  json_annotation:

dev_dependencies:
  flutter_test:
    sdk: flutter
  freezed:
  build_runner:
  json_serializable:

Android Sunflower では Plant.kt にデータクラスが定義されている:

data class Plant(
    val plantId: String,
    val name: String,
    val description: String,
    val growZoneNumber: Int,
    val wateringInterval: Int = 7,
    val imageUrl: String = ""
)

freezed では下記のようになる:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'plant.freezed.dart';
part 'plant.g.dart';


sealed class Plant with _$Plant {
  const factory Plant({
    required String plantId,
    required String name,
    required String description,
    required int growZoneNumber,
    (7) int wateringInterval,
    ('') String imageUrl,
  }) = _Plant;

  factory Plant.fromJson(Map<String, Object?> json) => _$PlantFromJson(json);
}

dart run build_runner build を実行して、コード生成したら準備完了!

試しに、りんごのJSONパースのテストが動作するか確認するテストを書いてみよう:

import 'dart:convert';

import 'package:flutter_sunflower/data/plant.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  test('plant ...', () {
    const json = """
{
    "plantId": "malus-pumila",
    "name": "Apple",
    "description": "An apple is a sweet",
    "growZoneNumber": 3,
    "imageUrl": "https://upload.wikimedia.org/wikipedia/commons/5/55/Apple_orchard_in_Tasmania.jpg"
}
""";
    final plant = Plant.fromJson(jsonDecode(json));

    expect(plant.plantId, 'malus-pumila');
    expect(plant.name, 'Apple');
    expect(plant.description, 'An apple is a sweet');
    expect(plant.growZoneNumber, 3);
    expect(plant.wateringInterval, 7); // default value
    expect(plant.imageUrl,
        'https://upload.wikimedia.org/wikipedia/commons/5/55/Apple_orchard_in_Tasmania.jpg');
  });
}

さて、assets/plants.json を assets に追加し、これをパースしてみよう。

unit_test で assets からデータを参照する場合は、先に TestWidgetsFlutterBinding.ensureInitialized(); を実行するのを忘れずに。

リストの場合は、 jsonDecodeList<dynamic> で受けてから map すると良い:

import 'dart:convert';

import 'package:flutter/services.dart';
import 'package:flutter_sunflower/data/plant.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  setUp(() {
    TestWidgetsFlutterBinding.ensureInitialized();
  });

  test('plant list...', () async {
    final json = await rootBundle.loadString('assets/plant.json');
    final List<dynamic> list = jsonDecode(json);
    final plantList = list.map((e) => Plant.fromJson(e)).toList();

    expect(plantList.first.plantId, 'malus-pumila');
  });
}
i53i53

Riverpod で Plant List の Provider を作成しよう:

どうせ後でHooks を使うことになるから hooks_riverpod にした。riverpod_generator も使ってみることにする:

# pubspec.yaml
dependencies:
  ...
  flutter_hooks:
  hooks_riverpod:
  riverpod_annotation:

dev_dependencies:
  ...
  build_runner:
  riverpod_generator:

provider はこんな感じにしてみた。

unit_test で モックするために json を拾ってくる Provider を分離しておく:

import 'dart:convert';

import 'package:flutter/services.dart';
import 'package:flutter_sunflower/data/plant.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'plant_list_provider.g.dart';


Future<String> plantJson(PlantJsonRef ref) async {
  return rootBundle.loadString('assets/plant.json');
}


Future<List<Plant>> plantList(PlantListRef ref) async {
  final json = await ref.watch(plantJsonProvider.future);
  final List<dynamic> list = jsonDecode(json);
  return list.map((e) => Plant.fromJson(e)).toList();
}

build_runner を build コマンドで実行してコードを自動生成する:

$ dart run build_runner build

また、watch コマンドの場合は アノテーションのあるコードを保存した時に自動的にコード生成してくれる、お好みでお好きな方を使おう (watch 推奨):

$ dart run build_runner build
i53i53

作成した provider の unit_test を書いてみる。

まず、testのディレクトリに createContainer の util を追加しておく:

/// test/utils.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';

ProviderContainer createContainer({
  List<Override> overrides = const [],
}) {
  final container = ProviderContainer(
    overrides: overrides,
  );
  addTearDown(container.dispose); 
  return container;
}
import 'package:flutter_sunflower/data/plant.dart';
import 'package:flutter_sunflower/provider/plant_list_provider.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../utils.dart';

void main() {
  test('plant list provider ...', () async {
    const mockJson = """
[
  {
    "plantId": "malus-pumila",
    "name": "Apple",
    "description": "An apple is a sweet, edible fruit produced by an apple tree (Malus pumila).",
    "growZoneNumber": 3,
    "wateringInterval": 30,
    "imageUrl": "https://upload.wikimedia.org/wikipedia/commons/5/55/Apple_orchard_in_Tasmania.jpg"
  },
  {
    "plantId": "beta-vulgaris",
    "name": "Beet",
    "description": "The beetroot is the taproot portion of the beet plant",
    "growZoneNumber": 2,
    "wateringInterval": 7,
    "imageUrl": "https://upload.wikimedia.org/wikipedia/commons/2/29/Beetroot_jm26647.jpg"
  }
]
""";

    final container = createContainer(
      overrides: [
        /// テスト用の json で override しておく
        plantJsonProvider.overrideWith((ref) async => mockJson),
      ],
    );

    // expect(container.read(plantListProvider), const AsyncLoading<List<Plant>>());

    final result = await container.read(plantListProvider.future);
    expect(result.map((e) => e.name), ['Apple', 'Beet']);
  });
}

こちらもどうぞ:
https://zenn.dev/i53/articles/26f15e23b5aa12

i53i53

Plant list のセルを作っていく。

Android Sunflower では下記のような実装になっている:

@Composable
fun ImageListItem(name: String, imageUrl: String, onClick: () -> Unit) {
    Card(
        onClick = onClick,
        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
        modifier = Modifier
            .padding(horizontal = dimensionResource(id = R.dimen.card_side_margin))
            .padding(bottom = dimensionResource(id = R.dimen.card_bottom_margin))
    ) {
        Column(Modifier.fillMaxWidth()) {
            SunflowerImage(
                model = imageUrl,
                contentDescription = stringResource(R.string.a11y_plant_item_image),
                Modifier
                    .fillMaxWidth()
                    .height(dimensionResource(id = R.dimen.plant_item_image_height)),
                contentScale = ContentScale.Crop
            )
            Text(
                text = name,
                textAlign = TextAlign.Center,
                maxLines = 1,
                style = MaterialTheme.typography.titleMedium,
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(vertical = dimensionResource(id = R.dimen.margin_normal))
                    .wrapContentWidth(Alignment.CenterHorizontally)
            )
        }
    }
}

画像はglide:composeで表示している:

@Composable
fun SunflowerImage(
    model: Any?,
    contentDescription: String?,
    modifier: Modifier = Modifier,
    alignment: Alignment = Alignment.Center,
    contentScale: ContentScale = ContentScale.Fit,
    alpha: Float = DefaultAlpha,
    colorFilter: ColorFilter? = null,
    requestBuilderTransform: RequestBuilderTransform<Drawable> = { it },
) {
    if (LocalInspectionMode.current) {
        Box(modifier = modifier.background(Color.Magenta))
        return
    }
    GlideImage(
        model = model,
        contentDescription = contentDescription,
        modifier = modifier,
        alignment = alignment,
        contentScale = contentScale,
        alpha = alpha,
        colorFilter = colorFilter,
        requestBuilderTransform = requestBuilderTransform,
        loading = placeholder {
            Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
                CircularProgressIndicator(Modifier.size(40.dp))
            }
        }
    )
}

Flutter 側では cached_network_image をGlideの代替として使用した:

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_sunflower/data/plant.dart';

class PlantListItemView extends StatelessWidget {
  const PlantListItemView(this.plant, {super.key});

  final Plant plant;

  
  Widget build(BuildContext context) {
    final textTheme = Theme.of(context).textTheme;
    return Card(
      margin: const EdgeInsets.only(left: 12, right: 12, bottom: 26),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          CachedNetworkImage(
            imageUrl: plant.imageUrl,
            width: double.infinity,
            height: 95,
            fit: BoxFit.cover,
            fadeInDuration: Duration.zero,
            fadeOutDuration: Duration.zero,
            progressIndicatorBuilder: (_, __, ___) => const Center(
              child: SizedBox(
                width: 40,
                height: 40,
                child: CircularProgressIndicator(),
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 16),
            child: Text(
              plant.name,
              textAlign: TextAlign.center,
              maxLines: 1,
              style: textTheme.titleMedium,
            ),
          ),
        ],
      ),
    );
  }
}

CardのThemeもAndroid Sunflower と同等のものにしておく:

final colorScheme = ColorScheme.fromSeed(seedColor: const Color(0xFF256F00));
return MaterialApp(
  title: 'Flutter Sunflower',
  theme: ThemeData(
  colorScheme: colorScheme,
  ...
  cardTheme: CardTheme(
    color: colorScheme.secondaryContainer,
    clipBehavior: Clip.antiAlias,
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.only(
        topLeft: Radius.zero,
        topRight: Radius.circular(12),
        bottomLeft: Radius.circular(12),
        bottomRight: Radius.zero,
      ),
    ),
  ),
  ...

後は GridView でセルを表示するだけ:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_sunflower/plant_list_item_view.dart';
import 'package:flutter_sunflower/provider/plant_list_provider.dart';

class PlantListScreen extends ConsumerWidget {
  const PlantListScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final plantList = ref.watch(plantListProvider);
    return switch (plantList) {
      AsyncData(:final value) => GridView.builder(
          gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 2,
          ),
          padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 24),
          itemCount: value.length,
          itemBuilder: (context, index) {
            value.sort((a, b) => a.name.compareTo(b.name));
            final plant = value[index];
            return PlantListItemView(plant);
          },
        ),
      _ => const SizedBox.shrink(),
    };
  }
}
compose flutter
i53i53

TODO: Plant list のセルをタップした時の詳細ページの実装

i53i53

ThemeData で floatingActionButton のテーマも更新しておこう:

   return MaterialApp(
      title: 'Flutter Sunflower',
      theme: ThemeData(
        ...
        floatingActionButtonTheme: const FloatingActionButtonThemeData(
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.only(
              topLeft: Radius.zero,
              topRight: Radius.circular(12),
              bottomLeft: Radius.circular(12),
              bottomRight: Radius.zero,
            ),
          ),
        ),
i53i53
ERROR:D8: Cannot fit requested classes in a single dex file (# methods: 71169 > 65536)
com.android.builder.dexing.DexArchiveMergerException: Error while merging dex archives: 
The number of method references in a .dex file cannot exceed 64K.

そろそろライブラリの追加によって Android 側で multidexのエラーが出る頃かもしれない
multidex がデフォルトで有効になる、minSdkVersion を 21以上に更新しておこう

    defaultConfig {
        // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
        applicationId "com.example.flutter_sunflower"
        // You can update the following values to match your application needs.
        // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
        minSdkVersion 21 // これ

推奨は23である:
https://twitter.com/minSdkVersion/status/1204145130673975311

i53i53

植物の説明文の表示に flutter_widget_from_html を追加する:
https://pub.dev/packages/flutter_widget_from_html

また、HTML内のリンクをタップした時に外部ブラウザを開けるようにするために、AndroidManifest.xml に下記を追加しておく:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.INTERNET" />

    <queries>    
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="https" />
        </intent>
    </queries>
    ...

詳細:
https://pub.dev/packages/url_launcher#android