見出し画像

Flutterアクセシビリティ実装ハンズオン(前編)


はじめに


こんにちは、株式会社SHIFT DAAEテクノロジーグループでFlutterアプリ開発をしている大矢です。
Flutterアプリにおけるアクセシビリティ実装を調べてみましたので、今回はアクセシビリティ対応が不十分なサンプルアプリをステップバイステップで修正するハンズオン形式で記事にしてみました。
前後編で構成し、前編ではサンプルアプリの問題点の確認まで、後編でアプリの修正を行います。
環境のある方はぜひFlutterプロジェクトを新規作成して手を動かしてみてください。

必要なもの

  • Mac or WindowsPC
    iOS用アプリの実行・検証にMacが必要ですが、Androidのみの場合はWindowsでも可能です。

  • Flutter開発環境

  • Android端末 or Androidシミュレータ
    途中、Play Storeからのアプリのインストールが必要なため、実機の方がおすすめです。

要約

  • Flutterはフレームワークレベルでアクセシビリティ実装のための機能が用意されているので、効果的に使う方法を押さえておきましょう。

  • アクセシビリティ実装ガイドラインに準拠しているかどうかは、実行プラットフォームごとの検証ツールやWidgetテストで確認可能。

    • ただし、検証ツールとWidgetテストの結果が一致しない場合もあるので注意。

Flutter アクセシビリティ実装の基本情報


この項ではアクセシビリティ実装を始める前に、基本的な情報を確認します。

Flutterにおけるアクセシビリティ実装については、公式ドキュメントにガイドがあります。

まずはここを見ていきましょう。

標準的にサポートされる3要素

ガイドによると、Flutterでは以下三つの機能がフレームワークとしてサポートされています。

  1. Large fonts

  2. Screen readers

  3. Sufficient contrast

Large fonts

Android、iOSともにOSの設定でフォントサイズを変更することができます。

例:Androidの設定画面

Androidの設定画面

この設定をFlutterアプリにも適用する機能が標準で用意されています。
たとえば、Textウィジェットでは特に何もしなくても普通に使うだけでOS設定によってフォントサイズが変化します。

Screen reader

音声読み上げ機能です。AndroidならTalkBack、iOSならVoiceOverという機能があります。
こちらも特別意識しなくてもある程度対応できるようになっており、Textウィジェットの表示テキストや、BottomNavigationBarItemクラスのlabel要素、Tabウィジェットのtext要素などで自動的に読み上げラベルが設定されます。

ただ、時により表示する文字列と読み上げ内容を変えたいことがあると思います。そのような場合は、ウィジェットに用意されている読み上げ用ラベルのオプションを使ったり、Semanticsウィジェットを使うことができます。

const Text(
  '月', // 画面上は"月"と表示する
  semanticsLabel: '月曜日', // 読み上げでは"月曜日"と読む
),

上記の例では、Textウィジェットに予め用意されているsemanticsLabelオプションを使います。semanticsLabelを指定しない場合画面に表示する文字列を発声するため(OSによる場合がありますが)「つき」と読み上げられてしまい、何のことかわからない可能性があります。
そこで、semanticsLabelに省略形ではない「月曜日」を当てることで、より確実に意味を伝えることができます。

Semantics(
  label: 'ボタンの振る舞いなど',
  child: IconButton(
    icon: NoTextIcon(), // 任意の文字がないウィジェット
    onPressed: () {
      // タップ時の処理
    },
  ),
),

上記の例では、IconButtonのiconに文字列がないイラスト等のアイコンがセットされています。
子要素にTextウィジェット等読み上げ機能に対応するウィジェットがあれば読み上げが自動的に実装可能ですが、そうでない場合はSemanticsウィジェットを使ってラベルをつけてあげる必要があります。

Semanticsウィジェットには多数のプロパティがあり、対象のウィジェットがどのような役割を持つのかを説明したり、追加の振る舞いを与えることができます。以下は一部例です。
なお、Semanticsの子ウィジェットによっては、以下のような属性も自動的に付く場合があり、テストで想定通りの属性が付いているか(付き過ぎていないか)確認する必要があります。

  • button
    trueにすると、「ボタンである」とマークされ、読み上げ時に各OSごとの表現でタップ操作が可能であることが案内されます。

  • checked
    bool値を与えることで、チェック済みかチェックされていないかが案内されます。チェックボックス等で使います。

  • toggled
    bool値を与えることで、オンかオフかが案内されます。オンオフ切り替えスイッチ等で使います。

その他にも、さまざまなアクションに対してコールバックを設定することもできます。
コールバックで追加の音声読み上げ等を実行しても良いかもしれません。

参考:

Sufficient contrast

背景色と前景色のコントラストが十分であるかどうかという観点です。 ボタンやテキストフィールドのヒントテキストなど、特に何も指定しなければアプリのテーマカラーを使って十分なコントラストを確保して描画するウィジェットが多数あります。
とはいえ、必ずしもテーマのみに沿ったデザインができない場合もあり、注意が必要です。
コントラストが確保できているかのチェックは後述します。

Flutterのドキュメントにも書いてある通りですが、フォントの大きさに応じてW3Cが推奨するコントラスト比があります。
原則、この推奨値に沿ったデザインをすることが望ましいです。

  • At least 4.5:1 for small text (below 18 point regular or 14 point bold)

  • At least 3.0:1 for large text (18 point and above regular or 14 point and above bold)

テーマを使って推奨値を保って効率よく色指定を行う方法もあり、後編で紹介します。

アクセシビリティ検証ツールの紹介


Android、iOSそれぞれにアクセシビリティ対応ができているか確認するツールがあります。
いずれもFlutterドキュメントにも記載があるため、参照ください。
以後記事に沿ってハンズオンを行う方は、リンク先に沿ってツールのセットアップを行ってください。

サンプルアプリで実装


ここからは、私の作成したサンプルアプリを実際に動かしながら、アクセシビリティにおけるどのような問題があるのかを確認していきます。
環境のある方はコードをコピーしてお手元で動かしてみてください。

サンプルアプリ(初期状態)

サンプルアプリは以下のようなものを用意しました。
気持ち悪いUIもあると思いますが、後述する内容の仕込みもありますので一旦スルーしてください。
※ 基本的にUIの実装しか行っていません。

サンプルアプリのログイン画面
サンプルアプリのリスト画面
// main.dart
import 'package:accessibility/login_page.dart';
import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Accessibility Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
      ),
      darkTheme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          brightness: Brightness.dark,
          seedColor: Colors.blue,
        ),
      ),
      home: const LoginPage(),
    );
  }
}
// login_page.dart
import 'package:accessibility/list_page.dart';
import 'package:flutter/material.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Login'),
        centerTitle: true,
      ),
      body: const Center(
        child: Padding(
          padding: EdgeInsets.all(15),
          child: Column(
            children: [
              EMailTextField(),
              SizedBox(height: 10),
              PasswordTextField(),
              SizedBox(height: 10),
              ForgotPasswordText(),
              SizedBox(height: 10),
              LoginButton(),
              SizedBox(height: 20),
              SignUpButton(),
            ],
          ),
        ),
      ),
    );
  }
}

class EMailTextField extends StatelessWidget {
  const EMailTextField();

  @override
  Widget build(BuildContext context) {
    return const TextField(
      decoration: InputDecoration(
        hintText: 'Email address',
        hintStyle: TextStyle(color: Colors.grey),
      ),
    );
  }
}

class PasswordTextField extends StatelessWidget {
  const PasswordTextField();

  @override
  Widget build(BuildContext context) {
    return const TextField(
      decoration: InputDecoration(
        hintText: 'Password',
        hintStyle: TextStyle(color: Colors.grey),
      ),
      obscureText: true,
    );
  }
}

class ForgotPasswordText extends StatelessWidget {
  const ForgotPasswordText();

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    return RichText(
      text: TextSpan(
        children: [
          TextSpan(
            text: 'Click ',
            style: TextStyle(color: colorScheme.onBackground),
          ),
          const WidgetSpan(child: PasswordResetButton()),
          TextSpan(
            text: ' if you forgot your password',
            style: TextStyle(color: colorScheme.onBackground),
          ),
        ],
      ),
    );
  }
}

class PasswordResetButton extends StatelessWidget {
  const PasswordResetButton();

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {},
      child: const SizedBox(
        width: 40,
        child: Text(
          'here',
          style: TextStyle(color: Colors.grey),
        ),
      ),
    );
  }
}

class LoginButton extends StatelessWidget {
  const LoginButton();

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    return GestureDetector(
      // ListPageを表示
      onTap: () {
        Navigator.of(context).push(
          MaterialPageRoute(
            builder: (context) => const ListPage(),
          ),
        );
      },
      child: Container(
        height: 40,
        width: 365,
        padding: const EdgeInsets.all(10),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(10),
          color: colorScheme.primary,
          boxShadow: [
            BoxShadow(
              color: colorScheme.shadow,
              blurRadius: 3,
            ),
          ],
        ),
        child: Center(
          child: Text(
            'Login',
            style: TextStyle(fontSize: 15, color: colorScheme.onPrimary),
          ),
        ),
      ),
    );
  }
}

class SignUpButton extends StatelessWidget {
  const SignUpButton();
  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    return GestureDetector(
      onTap: () {},
      child: Container(
        height: 40,
        width: 365,
        padding: const EdgeInsets.all(10),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(10),
          color: colorScheme.secondary,
          boxShadow: [
            BoxShadow(
              color: colorScheme.shadow,
              blurRadius: 3,
            ),
          ],
        ),
        child: Center(
          child: Text(
            'Sign up',
            style: TextStyle(
              fontSize: 15,
              color: colorScheme.onSecondary,
            ),
          ),
        ),
      ),
    );
  }
}
// list_page.dart
import 'package:flutter/material.dart';

class ListPage extends StatelessWidget {
  const ListPage();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('List'),
        centerTitle: true,
      ),
      body: const Stack(
        children: [
          ItemList(),
          Align(
            alignment: Alignment.bottomRight,
            child: Padding(
              padding: EdgeInsets.only(bottom: 50, right: 30),
              child: AddButton(),
            ),
          ),
        ],
      ),
    );
  }
}

class ItemList extends StatelessWidget {
  const ItemList();
  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Column(
        children: List.generate(
          15,
          (index) => const TileComponent(),
        ),
      ),
    );
  }
}

class TileComponent extends StatelessWidget {
  const TileComponent();

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 15),
      child: ListTile(
        onTap: () {},
        leading: ClipRRect(
          borderRadius: BorderRadius.circular(10),
          child: Image.network('https://picsum.photos/200/200'),
        ),
        title: const Text('List Title'),
      ),
    );
  }
}

class AddButton extends StatelessWidget {
  const AddButton();
  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    return GestureDetector(
      onTap: () {},
      child: Container(
        height: 38,
        width: 38,
        decoration: BoxDecoration(
          shape: BoxShape.circle,
          color: colorScheme.primary,
        ),
        child: Icon(
          Icons.add,
          color: colorScheme.onPrimary,
        ),
      ),
    );
  }
}

アクセシビリティ検証ツールの実行

Android

まずAndroidで上記のアプリを実行して、検証ツール(ユーザー補助検証ツール)を実行してみます。
すると、以下のように検証ツールからの提案が表示されました。
(ご自身の端末で確認する場合は、オレンジ枠内をタップすると詳細が表示されます)

ツールからの提案の要約は画像の下に列挙します。

サンプルアプリのログイン画面に対する、ツールからの提案
サンプルアプリのリスト画面に対する、ツールからの提案
  • ログイン画面

    1. メールアドレスボックス、パスワードボックス
      背景色とヒントテキストのコントラストが小さい

    2. パスワードリセットボタン
      タップ範囲が縦に狭すぎる

    3. Loginボタン
      タップ範囲が縦に狭すぎる
      「Login」というラベルが他ウィジェット(AppBarのタイトルの"Login")と被っている

    4. Sign upボタン
      タップ範囲が縦に狭すぎる

  • リスト画面

    1. リストタイル
      複数のリストタイルのラベルが同じ
      -> これは全てのタイルのタイトルが"List Title"であるためですが、実用的なアプリの場合このような タイトルは現実的ではないので、当記事ではスルーします。

    2. プラスボタン
      ラベルが設定されていない
      タップ範囲が縦横に狭すぎる

iOS

iOSでも同じアプリを実行して、検証ツール(Accessibility Inspector)を実行してみます。
iOSでは、Androidと指摘内容が異なりました。

サンプルアプリのログイン画面に対する、Accessibility Inspectorからの提案
サンプルアプリのリスト画面に対する、Accessibility Inspectorからの提案
  • ログイン画面
    指摘なし

  • リスト画面

    1. スクロールビュー
      ラベルが設定されていない

    2. プラスボタン
      ラベルが設定されていない

OSによる違い

AndroidとiOSでは指摘内容に差が出ました。 違いは以下の通りです。
違いはありますが、Flutterアプリですので今回は原則全ての指摘に対応します。(ラベル被りは対応しません。)

  • コントラスト

    • Androidではメール、パスワード入力ボックスのヒントテキストのコントラストが不足している

    • iOSでは指摘されない

  • ボタン

    • Androidではボタンの縦横がそれぞれ48dp以上でないと小さすぎると指摘される

    • iOSでは指摘されない(20dp未満になるとiOSでも指摘される)
      ->この点は後述するFlutterのWidgetテストで確認する方法ではiOSは44dpが境目になるため、やや不思議な挙動です。
      また、Apple公式のUIデザインガイドでも44 * 44が推奨されています。

iOSアプリでのボタンの大きさに関するAccessibility Inspectorの指摘内容
  • スクロールビューへのラベル

    • Androidでは無くても何も指摘されない

    • iOSでは無いと指摘される

  • 複数ウィジェットのラベル被り

    • Androidでは指摘される

    • iOSでは指摘されない

Widget Testでの確認


アクセシビリティ対応状況を機械的に確認する方法として、Widgetテストを書くことも可能です。

検査したいWidgetに対してWidgetテストを書くことで、以下の観点を確認できます。

  • ボタンのタップ範囲の大きさが一定以上か(AndroidとiOSそれぞれの基準で)

  • 色のコントラストが十分に確保されているか

  • Semanticsラベルが設定されているか

つまり、ここまで説明してきたアクセシビリティ実装のための観点のうち、OS設定により変化するフォントサイズ(Large fonts)以外の観点は基本的にWidgetテストで確認可能です。

Widgetテストの書き方

基本的な書き方についても、Flutterのドキュメントに記載されています。

テスト作成

では、今回作成したサンプルアプリに対して、テストを作成してみます。
数ケースエラーになりますが、想定通りです。

// login_page_test.dart
import 'package:accessibility/login_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  group('コントラスト比', () {
    testWidgets('✅Eメールアドレスフィールド', (tester) async {
      final handle = tester.ensureSemantics();
      await tester.pumpWidget(_scaffold(const EMailTextField()));
      // Androidのアクセシビリティ検証ツールでは、コントラスト比が不足していると指摘されるが、テストは通ってしまう
      await expectLater(tester, meetsGuideline(textContrastGuideline));
      handle.dispose();
    });

    testWidgets('✅パスワードフィールド', (tester) async {
      final handle = tester.ensureSemantics();
      await tester.pumpWidget(_scaffold(const PasswordTextField()));
      // Androidのアクセシビリティ検証ツールでは、コントラスト比が不足していると指摘されるが、テストは通ってしまう
      await expectLater(tester, meetsGuideline(textContrastGuideline));
      handle.dispose();
    });

    // エラーになる
    testWidgets('✅パスワードリセットボタン', (tester) async {
      final handle = tester.ensureSemantics();
      await tester.pumpWidget(_scaffold(const PasswordResetButton()));
      await expectLater(tester, meetsGuideline(textContrastGuideline));
      handle.dispose();
    });

    testWidgets('✅ログインボタン', (tester) async {
      final handle = tester.ensureSemantics();
      await tester.pumpWidget(_material(const LoginButton()));
      await expectLater(tester, meetsGuideline(textContrastGuideline));
      handle.dispose();
    });

    testWidgets('✅サインアップボタン', (tester) async {
      final handle = tester.ensureSemantics();
      await tester.pumpWidget(_material(const SignUpButton()));
      await expectLater(tester, meetsGuideline(textContrastGuideline));
      handle.dispose();
    });
  });

  group('タップ範囲', () {
    // エラーになる
    testWidgets('✅パスワードリセットボタン', (tester) async {
      final handle = tester.ensureSemantics();
      await tester.pumpWidget(_center(const PasswordResetButton()));
      await expectLater(
        tester,
        meetsGuideline(androidTapTargetGuideline),
      );
      await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
      handle.dispose();
    });

    // エラーになる
    testWidgets('✅ログインボタン', (tester) async {
      final handle = tester.ensureSemantics();
      await tester.pumpWidget(_center(const LoginButton()));
      await expectLater(
        tester,
        meetsGuideline(androidTapTargetGuideline),
      );
      await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
      handle.dispose();
    });

    // エラーになる
    testWidgets('✅サインアップボタン', (tester) async {
      final handle = tester.ensureSemantics();
      await tester.pumpWidget(_center(const SignUpButton()));
      await expectLater(
        tester,
        meetsGuideline(androidTapTargetGuideline),
      );
      await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
      handle.dispose();
    });
  });

  group('ラベル設定', () {
    testWidgets('✅Eメールアドレスフィールド', (tester) async {
      final handle = tester.ensureSemantics();
      await tester.pumpWidget(_scaffold(const EMailTextField()));
      await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
      handle.dispose();
    });
    testWidgets('✅パスワードフィールド', (tester) async {
      final handle = tester.ensureSemantics();
      await tester.pumpWidget(_scaffold(const PasswordTextField()));
      await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
      handle.dispose();
    });
    testWidgets('✅ログインボタン', (tester) async {
      final handle = tester.ensureSemantics();
      await tester.pumpWidget(_material(const LoginButton()));
      await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
      handle.dispose();
    });
    testWidgets('✅サインアップボタン', (tester) async {
      final handle = tester.ensureSemantics();
      await tester.pumpWidget(_material(const SignUpButton()));
      await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
      handle.dispose();
    });
    testWidgets('✅パスワードリセットボタン', (tester) async {
      final handle = tester.ensureSemantics();
      await tester.pumpWidget(_material(const PasswordResetButton()));
      await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
      handle.dispose();
    });
  });
}

MaterialApp _material(Widget widget) => MaterialApp(
      home: widget,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
      ),
    );

MaterialApp _scaffold(Widget widget) => _material(Scaffold(body: widget));

MaterialApp _center(Widget widget) => _material(Center(child: widget));
// list_page_test.dart
import 'package:accessibility/list_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail_image_network/mocktail_image_network.dart';

void main() {
  group('コントラスト比', () {
    testWidgets('✅リスト', (tester) async {
      final handle = tester.ensureSemantics();
      // 画像をWebから取得しているため、mocktail_image_networkを使用してHTTPクライアントをモックしています。
      // アクセシビリティ対応とは直接関係ありません。
      await mockNetworkImages(() async {
        await tester.pumpWidget(_scaffold(const ItemList()));
        await expectLater(tester, meetsGuideline(textContrastGuideline));
      });
      handle.dispose();
    });
    testWidgets('✅タイル', (tester) async {
      final handle = tester.ensureSemantics();
      await mockNetworkImages(() async {
        await tester.pumpWidget(_scaffold(const TileComponent()));
        await expectLater(tester, meetsGuideline(textContrastGuideline));
      });
      handle.dispose();
    });
    testWidgets('✅追加ボタン', (tester) async {
      final handle = tester.ensureSemantics();
      await tester.pumpWidget(_scaffold(const AddButton()));
      await expectLater(tester, meetsGuideline(textContrastGuideline));
      handle.dispose();
    });
  });

  group('タップ範囲', () {
    testWidgets('✅タイル', (tester) async {
      final handle = tester.ensureSemantics();
      await mockNetworkImages(() async {
        await tester.pumpWidget(_scaffold(const TileComponent()));
        await expectLater(
          tester,
          meetsGuideline(androidTapTargetGuideline),
        );
        await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
      });
      handle.dispose();
    });

    // エラーになる
    testWidgets('✅追加ボタン', (tester) async {
      final handle = tester.ensureSemantics();
      await tester.pumpWidget(_center(const AddButton()));
      await expectLater(
        tester,
        meetsGuideline(androidTapTargetGuideline),
      );
      await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
      handle.dispose();
    });
  });
  group('ラベル', () {
    testWidgets('✅リスト', (tester) async {
      final handle = tester.ensureSemantics();
      await mockNetworkImages(() async {
        await tester.pumpWidget(_scaffold(const ItemList()));
        await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
      });
      handle.dispose();
    });

    // エラーになる
    testWidgets('✅追加ボタン', (tester) async {
      final handle = tester.ensureSemantics();
      await tester.pumpWidget(_center(const AddButton()));
      await expectLater(
        tester,
        meetsGuideline(labeledTapTargetGuideline),
      );
      handle.dispose();
    });
  });
}

MaterialApp _material(Widget widget) => MaterialApp(
      home: widget,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
      ),
    );

MaterialApp _scaffold(Widget widget) => _material(Scaffold(body: widget));

MaterialApp _center(Widget widget) => _material(Center(child: widget));

ご覧の通りですが、テストを書いても必ずしもアクセシビリティ検証ツールと同じ結果になるわけではありませんでした。
ウィジェット実装やテストを修正すれば検出できる可能性はありますが、現状実現できていません。
似たような事象はGithubにもissueが上がっています。

ともあれ、ある程度のテスト実装は完了しましたので、後編ではこのテスト結果も使いながらサンプルアプリを修正していきます。

後編に向けて


前編ではサンプルアプリに対してアクセシビリティ実装ができているかどうか確認してきました。
結果として数件の不備が見つかりましたので、後編ではこれらを修正して基本的なアクセシビリティ実装ができているアプリにしていきますので、ぜひご覧ください。

\もっと身近にもっとリアルに!DAAE 公式 X/


執筆者プロフィール:大矢 茂士
SHIFT DAAE(ダーエ)テクノロジーグループ所属の開発エンジニアです。

お問合せはお気軽に

SHIFTについて(コーポレートサイト)

SHIFTのサービスについて(サービスサイト)

SHIFTの導入事例

お役立ち資料はこちら

SHIFTの採用情報はこちら

PHOTO:UnsplashAlex Presa