【Flutter】コピペで使える！ログイン画面

この記事は[Flutter Advent Calendar](https://qiita.com/advent-calendar/2021/flutter)の7日目の記事です。

---

突然ですが皆さんはなぜFlutterを使っているのでしょうか？
さまざまな理由があると思いますが「開発時間を短縮できる」という点が大きいのではないでしょうか。

今回は「開発時間の節約」を目的として、ログイン画面のテンプレートを複数用意しました。
ログイン画面はユーザーが使用する場面はとても少ないですが、ほとんどのアプリにおいて必要です。

この部分に当てる時間を削り、アプリの本質的な価値を生み出す部分に時間を使いましょう！

## シンプル

TextFormFieldがあるだけのシンプルな画面です。これをベースに作成します。

|　　　　　　　　  　　　　　　　　   　　　　　　　　　　　　　　　　　　　　　　　　　|
|:-:|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238563/da999a5b-3901-3f5b-3c25-fceeda4b3f52.png" width=40%>|

<details><summary>サンプルコード</summary><div>
 
```
import 'package:flutter/material.dart';

class SimpleLoginScreen extends StatefulWidget {
  const SimpleLoginScreen({Key? key}) : super(key: key);

  @override
  _SimpleLoginScreenState createState() => _SimpleLoginScreenState();
}

class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
  String email = '';
  String password = '';
  bool hidePassword = true;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Simple Login Screen'),
      ),
      body: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 24),
        child: Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              const Text(
                'Login',
                style: TextStyle(
                  fontSize: 32,
                  fontWeight: FontWeight.bold,
                ),
              ),
              TextFormField(
                decoration: const InputDecoration(
                  icon: Icon(Icons.mail),
                  hintText: 'hogehoge@qmail.com',
                  labelText: 'Email Address',
                ),
                onChanged: (String value) {
                  setState(() {
                    email = value;
                  });
                },
              ),
              TextFormField(
                obscureText: hidePassword,
                decoration: InputDecoration(
                  icon: const Icon(Icons.lock),
                  labelText: 'Password',
                  suffixIcon: IconButton(
                    icon: Icon(
                      hidePassword ? Icons.visibility_off : Icons.visibility,
                    ),
                    onPressed: () {
                      setState(() {
                        hidePassword = !hidePassword;
                      });
                    },
                  ),
                ),
                onChanged: (String value) {
                  setState(() {
                    password = value;
                  });
                },
              ),
              const SizedBox(height: 15),
              ElevatedButton(
                onPressed: () {
                  Navigator.of(context).pop();
                },
                child: const Text('Login'),
              )
            ],
          ),
        ),
      ),
    );
  }
}
```
</div></details>


## Lottie
画面上部にアニメーションを追加した画面です。
画面に少し動きがあるだけで見栄えがいいですね!

[Lottie Files](https://lottiefiles.com/)でアニメーションファイルを探すことができます。
好みのアニメーションに切り替えて使用してください。

|　　　　　　　　  　　　　　　　　   　　　　　　　　　　　　　　　　　　　　　　　　　|
|:-:|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238563/dac0306d-7154-6f82-eb83-f9e76b74eb9f.gif" >|

https://pub.dev/packages/lottie

<details><summary>サンプルコード</summary><div>
 
```
import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';

// Lottieを使ったログインスクリーン
// アニメーション素材は以下のサイトから取得できる。
// https://lottiefiles.com/
// pubspec.yamlに以下を追加
// lottie: ^1.2.1
class LottieScreen extends StatefulWidget {
  const LottieScreen({Key? key}) : super(key: key);

  @override
  _LottieScreenState createState() => _LottieScreenState();
}

class _LottieScreenState extends State<LottieScreen> {
  String email = '';
  String password = '';
  bool hidePassword = true;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Lottie Screen'),
      ),
      body: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 36),
        child: Center(
          child: Column(
            children: [
              const Text(
                'Login',
                style: TextStyle(
                  fontSize: 32,
                  fontWeight: FontWeight.bold,
                ),
              ),
              // https://lottiefiles.com/38435-register を使用。
              // ページ内の'Lottie Animation URL'で取得したURLを貼り付ける
              Lottie.network(
                'https://assets9.lottiefiles.com/packages/lf20_jcikwtux.json',
                errorBuilder: (context, error, stackTrace) {
                  return const Padding(
                    padding: EdgeInsets.all(30.0),
                    child: CircularProgressIndicator(),
                  );
                },
              ),
              TextFormField(
                decoration: const InputDecoration(
                  icon: Icon(Icons.mail),
                  hintText: 'hogehoge@qmail.com',
                  labelText: 'Email Address',
                ),
                onChanged: (String value) {
                  setState(() {
                    email = value;
                  });
                },
              ),
              TextFormField(
                obscureText: hidePassword,
                decoration: InputDecoration(
                  icon: const Icon(Icons.lock),
                  labelText: 'Password',
                  suffixIcon: IconButton(
                    icon: Icon(
                      hidePassword ? Icons.visibility_off : Icons.visibility,
                    ),
                    onPressed: () {
                      setState(() {
                        hidePassword = !hidePassword;
                      });
                    },
                  ),
                ),
                onChanged: (String value) {
                  setState(() {
                    password = value;
                  });
                },
              ),
              const SizedBox(height: 15),
              ElevatedButton(
                onPressed: () {
                  Navigator.of(context).pop();
                },
                child: const Text('Login'),
              )
            ],
          ),
        ),
      ),
    );
  }
}

```
</div></details>

## Nuemorphic
ニューモーフィックデザインを取り入れたログイン画面です。
ニューモーフィズムとは、New + Skeumophism の造語です。[Skeumorphismについてはこちらをご覧ください。(Wiki)](https://ja.wikipedia.org/wiki/%E3%82%B9%E3%82%AD%E3%83%A5%E3%83%BC%E3%83%A2%E3%83%BC%E3%83%95%E3%82%A3%E3%82%BA%E3%83%A0) ~~「今年こそ流行る」と言われ続けて何年経ったのでしょうか....~~

|　　　　　　　　  　　　　　　　　   　　　　　　　　　　　　　　　　　　　　　　　　　|
|:-:|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238563/42c6ef7a-cdcf-64e8-ab89-3b260c73a91c.png" width=40%>|

実装にはflutter_neumorphicを使用しました。

https://pub.dev/packages/flutter_neumorphic

全体的にニューモーフィックデザインを取り入れているアプリでないと使うことは難しいと思いますが、一例として紹介しておきます。

<details><summary>サンプルコード</summary><div>
 
```
import 'package:flutter/material.dart';
import 'package:flutter_neumorphic/flutter_neumorphic.dart';

// pubspec.yamlに以下を追加
// flutter_neumorphic: ^3.2.0
class NeumorphicScreen extends StatefulWidget {
  const NeumorphicScreen({Key? key}) : super(key: key);

  @override
  _NeumorphicScreenState createState() => _NeumorphicScreenState();
}

class _NeumorphicScreenState extends State<NeumorphicScreen> {
  String email = '';
  String password = '';
  bool hidePassword = true;

  @override
  Widget build(BuildContext context) {
    return NeumorphicTheme(
      theme: const NeumorphicThemeData(
        baseColor: Color(0xffFFFFFF),
        lightSource: LightSource.topLeft,
        depth: 4,
        intensity: 0.7,
      ),
      child: Scaffold(
        appBar: NeumorphicAppBar(
          title: NeumorphicText(
            'Neumorphic screen',
            style: const NeumorphicStyle(
              depth: 14,
              color: Colors.black38,
            ),
            textStyle: NeumorphicTextStyle(
              fontSize: 20,
              fontWeight: FontWeight.bold,
            ),
          ),
          buttonStyle: NeumorphicStyle(
            shape: NeumorphicShape.convex,
            boxShape: NeumorphicBoxShape.roundRect(BorderRadius.circular(12)),
            depth: 6,
            lightSource: LightSource.topLeft,
          ),
        ),
        backgroundColor: NeumorphicTheme.baseColor(context),
        body: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 24),
          child: Center(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                NeumorphicText(
                  'Login',
                  style: const NeumorphicStyle(
                    depth: 14,
                    color: Colors.black38,
                  ),
                  textStyle: NeumorphicTextStyle(
                    fontSize: 40,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                const SizedBox(height: 25),
                Neumorphic(
                  style: NeumorphicStyle(
                    boxShape: NeumorphicBoxShape.roundRect(
                      BorderRadius.circular(12),
                    ),
                    depth: -6,
                    lightSource: LightSource.topLeft,
                  ),
                  padding: const EdgeInsets.symmetric(horizontal: 10),
                  child: TextFormField(
                    decoration: const InputDecoration(
                      icon: Icon(Icons.mail),
                      hintText: 'hogehoge@qmail.com',
                    ),
                    onChanged: (String value) {
                      setState(() {
                        email = value;
                      });
                    },
                  ),
                ),
                const SizedBox(height: 25),
                Neumorphic(
                  style: NeumorphicStyle(
                    boxShape: NeumorphicBoxShape.roundRect(
                      BorderRadius.circular(12),
                    ),
                    depth: -6,
                    lightSource: LightSource.topLeft,
                  ),
                  padding: const EdgeInsets.symmetric(horizontal: 10),
                  child: TextFormField(
                    obscureText: hidePassword,
                    decoration: InputDecoration(
                      icon: const Icon(Icons.lock),
                      hintText: 'your password',
                      suffixIcon: IconButton(
                        icon: Icon(
                          hidePassword
                              ? Icons.visibility_off
                              : Icons.visibility,
                        ),
                        onPressed: () {
                          setState(() {
                            hidePassword = !hidePassword;
                          });
                        },
                      ),
                    ),
                    onChanged: (String value) {
                      setState(() {
                        password = value;
                      });
                    },
                  ),
                ),
                const SizedBox(height: 25),
                NeumorphicButton(
                  style: NeumorphicStyle(
                    shape: NeumorphicShape.convex,
                    boxShape:
                        NeumorphicBoxShape.roundRect(BorderRadius.circular(12)),
                    depth: 6,
                    lightSource: LightSource.topLeft,
                  ),
                  onPressed: () {
                    Navigator.of(context).pop();
                  },
                  child: const Text('Login'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

```
</div></details>

## image + shader

画像にshaderを使用した例です。画像のパスは書き換えてください。

|　　　　　　　　  　　　　　　　　   　　　　　　　　　　　　　　　　　　　　　　　　　|
|:-:|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238563/10b8bbd4-efac-c7e2-ba96-3b6ecf464c90.png" width=40%>|

<details><summary>サンプルコード</summary><div>
 
```
class ImageScreen extends StatefulWidget {
  const ImageScreen({Key? key}) : super(key: key);

  @override
  _ImageScreenState createState() => _ImageScreenState();
}

class _ImageScreenState extends State<ImageScreen> {
  String email = '';
  String password = '';
  bool hidePassword = true;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        title: const Text(
          'image screen',
          style: TextStyle(color: Colors.white),
        ),
      ),
      extendBodyBehindAppBar: true,
      body: Stack(
        children: [
          ShaderMask(
            blendMode: BlendMode.srcATop,
            shaderCallback: (Rect bounds) {
              return const LinearGradient(
                begin: Alignment.topLeft,
                end: Alignment.bottomRight,
                colors: <Color>[Colors.black, Colors.black12],
              ).createShader(bounds);
            },
            child: SizedBox(
              height: MediaQuery.of(context).size.height,
              width: MediaQuery.of(context).size.width,
              child: Image.asset(
                'assets/images/background1.jpg',
                fit: BoxFit.fill,
              ),
            ),
          ),
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 24),
            decoration: BoxDecoration(
              color: Colors.white60.withOpacity(0.2),
            ),
            child: Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  const Text(
                    'Login',
                    style: TextStyle(
                      fontSize: 32,
                      fontWeight: FontWeight.bold,
                      color: Colors.white,
                    ),
                  ),
                  Container(
                    padding:
                        const EdgeInsets.only(bottom: 6, left: 12, right: 12),
                    margin: const EdgeInsets.symmetric(vertical: 10),
                    decoration: BoxDecoration(
                      color: Colors.white.withOpacity(0.5),
                      borderRadius: BorderRadius.circular(12),
                    ),
                    child: TextFormField(
                      decoration: const InputDecoration(
                        icon: Icon(Icons.mail),
                        hintText: 'hogehoge@qmail.com',
                        labelText: 'Email Address',
                      ),
                      onChanged: (String value) {
                        setState(() {
                          email = value;
                        });
                      },
                    ),
                  ),
                  Container(
                    padding:
                        const EdgeInsets.only(bottom: 6, left: 12, right: 12),
                    margin: const EdgeInsets.symmetric(vertical: 10),
                    decoration: BoxDecoration(
                      color: Colors.white.withOpacity(0.5),
                      borderRadius: BorderRadius.circular(12),
                    ),
                    child: TextFormField(
                      obscureText: hidePassword,
                      decoration: InputDecoration(
                        icon: const Icon(Icons.lock),
                        labelText: 'Password',
                        suffixIcon: IconButton(
                          icon: Icon(
                            hidePassword
                                ? Icons.visibility_off
                                : Icons.visibility,
                          ),
                          onPressed: () {
                            setState(() {
                              hidePassword = !hidePassword;
                            });
                          },
                        ),
                      ),
                      onChanged: (String value) {
                        setState(() {
                          password = value;
                        });
                      },
                    ),
                  ),
                  const SizedBox(height: 15),
                  ElevatedButton(
                    onPressed: () {
                      Navigator.of(context).pop();
                    },
                    child: const Text('Login'),
                  )
                ],
              ),
            ),
          )
        ],
      ),
    );
  }
}

```
</div></details>

## Animation1
ランダムな大きさの円がバックグラウンドで動きます。
[CustomPainter](https://api.flutter.dev/flutter/rendering/CustomPainter-class.html)を用いて実現しています。

|　　　　　　　　  　　　　　　　　   　　　　　　　　　　　　　　　　　　　　　　　　　|
|:-:|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238563/7d9fd4f3-24d2-2337-d50c-8297fcf329f0.gif">|

<details><summary>サンプルコード</summary><div>
 
```
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';

class Animation1 extends StatefulWidget {
  const Animation1({Key? key}) : super(key: key);

  @override
  _Animation1State createState() => _Animation1State();
}

class _Animation1State extends State<Animation1> {
  String email = '';
  String password = '';
  bool hidePassword = true;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: const Text('Simple Login Screen'),
        ),
        body: BackgroundAnimation1(
          size: MediaQuery.of(context).size,
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 24),
            child: Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  const Text(
                    'Login',
                    style: TextStyle(
                      fontSize: 32,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  TextFormField(
                    decoration: const InputDecoration(
                      icon: Icon(Icons.mail),
                      hintText: 'hogehoge@qmail.com',
                      labelText: 'Email Address',
                    ),
                    onChanged: (String value) {
                      setState(() {
                        email = value;
                      });
                    },
                  ),
                  TextFormField(
                    obscureText: hidePassword,
                    decoration: InputDecoration(
                      icon: const Icon(Icons.lock),
                      labelText: 'Password',
                      suffixIcon: IconButton(
                        icon: Icon(
                          hidePassword
                              ? Icons.visibility_off
                              : Icons.visibility,
                        ),
                        onPressed: () {
                          setState(() {
                            hidePassword = !hidePassword;
                          });
                        },
                      ),
                    ),
                    onChanged: (String value) {
                      setState(() {
                        password = value;
                      });
                    },
                  ),
                  const SizedBox(height: 15),
                  ElevatedButton(
                    onPressed: () {
                      Navigator.of(context).pop();
                    },
                    child: const Text('Login'),
                  )
                ],
              ),
            ),
          ),
        ));
  }
}

class BackgroundAnimation1 extends StatefulWidget {
  const BackgroundAnimation1({
    Key? key,
    required this.size,
    required this.child,
  }) : super(key: key);
  final Size size;
  final Widget child;

  @override
  _BackgroundAnimation1State createState() => _BackgroundAnimation1State();
}

class _BackgroundAnimation1State extends State<BackgroundAnimation1> {
  late Timer timer;
  late List<Particle> particles =
      List<Particle>.generate(90, (index) => Particle(size: widget.size));

  @override
  void initState() {
    super.initState();
    const duration = Duration(milliseconds: 1000 ~/ 60);
    timer = Timer.periodic(duration, (timer) {
      setState(() {
        for (var element in particles) {
          element.moveParticle();
        }
      });
    });
  }

  @override
  void dispose() {
    super.dispose();
    timer.cancel();
  }

  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.of(context).size;
    return Stack(
      children: [
        CustomPaint(
          size: size,
          painter: AnimationPainter1(particles),
        ),
        Center(
          child: SizedBox(
            height: size.height * 0.4,
            child: Card(
              color: Colors.white.withOpacity(0.9),
              child: widget.child,
            ),
          ),
        ),
      ],
    );
  }
}

class AnimationPainter1 extends CustomPainter {
  List<Particle> particles;
  AnimationPainter1(this.particles);

  @override
  void paint(Canvas canvas, Size size) {
    for (var e in particles) {
      canvas.drawCircle(
        e.pos,
        e.radius,
        Paint()
          ..style = PaintingStyle.fill
          ..color = e.color,
      );
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

class Particle {
  final Color color = getRandomColor();
  final double radius = getRandomVal(1, 5) * 9;

  double dx = getRandomVal(-0.25, 0.25);
  double dy = getRandomVal(-0.25, 0.25);

  late Size size;
  late double x = getRandomVal(0, size.width);
  late double y = getRandomVal(0, size.height);
  late Offset pos = Offset(x, y); // 現在の点の位置

  Particle({required this.size});

  void moveParticle() {
    Offset nextPos = pos + Offset(dx, dy);
    if (nextPos.dx < 0 ||
        size.width < nextPos.dx ||
        nextPos.dy < 0 ||
        size.height < nextPos.dy) {
      dx = -dx;
      dy = -dy;
      nextPos = pos + Offset(dx, dy);
    }
    pos = nextPos;
  }

  static Color getRandomColor() {
    final colorList = [
      Colors.red,
      Colors.blueAccent,
      Colors.orange,
      Colors.yellow,
    ];
    final rnd = Random();
    int index = rnd.nextInt(colorList.length);

    return colorList[index].withOpacity(0.25);
  }

  // min ~ max内のランダムな値を取得
  static double getRandomVal(double min, double max) {
    final rnd = Random();
    return rnd.nextDouble() * (max - min) + min;
  }
}

```
</div></details>

## Animation2
波打つようなエフェクトが特徴的な画面です。先ほど同様、CustomPainterを使って実現しています。[この動画を参考にさせて頂きました](https://www.youtube.com/watch?v=vyj8jVUQS6Q)。
動画内ではCustomClipperを使っていますが動作はほぼ同じです。

|　　　　　　　　  　　　　　　　　   　　　　　　　　　　　　　　　　　　　　　　　　　|
|:-:|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238563/2ece6d4b-9ce0-7ccd-426c-232437c67f7f.gif">|

<details><summary>サンプルコード</summary><div>
 
```
import 'dart:async';
import 'dart:math';

import 'package:flutter/material.dart';

class Animation2 extends StatefulWidget {
  const Animation2({Key? key}) : super(key: key);

  @override
  _Animation2State createState() => _Animation2State();
}

class _Animation2State extends State<Animation2> {
  String email = '';
  String password = '';
  bool hidePassword = true;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        title: const Text('Simple Login Screen'),
      ),
      extendBodyBehindAppBar: true,
      body: BackgroundAnimation2(
        size: MediaQuery.of(context).size,
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 24),
          child: Center(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                const Text(
                  'Login',
                  style: TextStyle(
                    fontSize: 32,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                TextFormField(
                  decoration: const InputDecoration(
                    icon: Icon(Icons.mail),
                    hintText: 'hogehoge@qmail.com',
                    labelText: 'Email Address',
                  ),
                  onChanged: (String value) {
                    setState(() {
                      email = value;
                    });
                  },
                ),
                TextFormField(
                  obscureText: hidePassword,
                  decoration: InputDecoration(
                    icon: const Icon(Icons.lock),
                    labelText: 'Password',
                    suffixIcon: IconButton(
                      icon: Icon(
                        hidePassword ? Icons.visibility_off : Icons.visibility,
                      ),
                      onPressed: () {
                        setState(() {
                          hidePassword = !hidePassword;
                        });
                      },
                    ),
                  ),
                  onChanged: (String value) {
                    setState(() {
                      password = value;
                    });
                  },
                ),
                const SizedBox(height: 15),
                ElevatedButton(
                  onPressed: () {
                    Navigator.of(context).pop();
                  },
                  child: const Text('Login'),
                )
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class BackgroundAnimation2 extends StatefulWidget {
  const BackgroundAnimation2({
    Key? key,
    required this.size,
    required this.child,
  }) : super(key: key);
  final Size size;
  final Widget child;

  @override
  _BackgroundAnimation2State createState() => _BackgroundAnimation2State();
}

class _BackgroundAnimation2State extends State<BackgroundAnimation2> {
  late Timer timer;
  double time = 0;

  @override
  void initState() {
    super.initState();
    const duration = Duration(milliseconds: 1000 ~/ 60); // 60fps
    timer = Timer.periodic(duration, (timer) {
      setState(() {
        time += 0.0025;
      });
    });
  }

  @override
  void dispose() {
    super.dispose();
    timer.cancel();
  }

  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.of(context).size;
    return Stack(
      children: [
        CustomPaint(
          size: size,
          painter: AnimationPainter2(
            waveColor: Colors.blueAccent.withOpacity(0.8),
            height: 0.25,
            time: time,
          ),
        ),
        Center(
          child: widget.child,
        ),
      ],
    );
  }
}

class AnimationPainter2 extends CustomPainter {
  double height;
  Color waveColor;
  double time;
  AnimationPainter2({
    required this.waveColor,
    required this.height,
    required this.time,
  });

  @override
  void paint(Canvas canvas, Size size) {
    Path path = Path();
    final double waveSpeed = time * 1080;
    final double fullSphere = time * pi * 2;
    final double normalizer = cos(fullSphere);
    final double waveWidth = pi / 270;
    const double waveHeight = 35.0;

    path.lineTo(0, size.height * height);
    for (int i = 0; i < size.width.toInt(); i++) {
      double calc = sin((waveSpeed - i) * waveWidth);
      path.lineTo(
        i.toDouble(),
        size.height * height + calc * waveHeight * normalizer,
      );
    }
    path.lineTo(size.width, 0);

    Paint wavePaint = Paint()..color = waveColor;
    canvas.drawPath(path, wavePaint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

```
</div></details>

## flutter_login
FlutterLoginは既製のログイン/サインアップウィジェットです。
下のgifはほぼサンプルコードと同じなので、Readmeをご覧ください。
実装も簡単なのでオススメです。

|　　　　　　　　  　　　　　　　　   　　　　　　　　　　　　　　　　　　　　　　　　　|
|:-:|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2238563/a0fb0fbc-53a6-b611-d544-5b520a6637cc.gif">|

https://pub.dev/packages/flutter_login

<details><summary>サンプルコード</summary><div>
 
```
import 'package:flutter/material.dart';
import 'package:flutter_login/flutter_login.dart';

const users = {
  'dribbble@gmail.com': '12345',
  'hunter@gmail.com': 'hunter',
};
// pubspec.yamlに以下を追加
// flutter_login: ^3.0.0
class LoginScreen extends StatelessWidget {
  const LoginScreen({Key? key}) : super(key: key);

  Duration get loginTime => const Duration(milliseconds: 2250);

  Future<String?> _authUser(LoginData data) {
    debugPrint('Name: ${data.name}, Password: ${data.password}');
    return Future.delayed(loginTime).then((_) {
      if (!users.containsKey(data.name)) {
        return 'User not exists';
      }
      if (users[data.name] != data.password) {
        return 'Password does not match';
      }
      return null;
    });
  }

  Future<String?> _signupUser(SignupData data) {
    debugPrint('Signup Name: ${data.name}, Password: ${data.password}');
    return Future.delayed(loginTime).then((_) {
      return null;
    });
  }

  Future<String?> _recoverPassword(String name) {
    debugPrint('Name: $name');
    return Future.delayed(loginTime).then((_) {
      if (!users.containsKey(name)) {
        return 'User not exists';
      }
      return null;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: FlutterLogin(
        title: 'ECORP',
        onLogin: _authUser,
        onSignup: _signupUser,
        onSubmitAnimationCompleted: () {
          Navigator.of(context).pop();
        },
        onRecoverPassword: _recoverPassword,
      ),
    );
  }
}

```
</div></details>

## 最後に
時間があったら更新します。

## 参考
- https://docs.flutter.dev/cookbook/forms/validation
- https://pub.dev/packages/lottie
- https://pub.dev/packages/flutter_neumorphic
- https://pub.dev/packages/flutter_login
- https://www.youtube.com/watch?v=vyj8jVUQS6Q