LoginSignup
17
11

More than 5 years have passed since last update.

あなたの寿命がわかる余命カウントアプリハンズオン

Posted at

完成版

20190318_090801.GIF

このアプリは自分の生年月日を入力することで、残りの寿命を知ることができるアプリです。自分に残された時間を知ることで、より今を大切に生きれるように!という思いを込めて作成しました...

Flutterの全ソースコードと解説を公開しているので、これからFlutterを学ぶ方がハンズオン形式でライトに実装できる内容になっています。

本アプリは以下の流れで作成していきます。
:zero: 事前準備
:one: レイアウト作成
:two: 日付入力と表示
:three: 残余名の計算
:four: グラフ表示

:zero: 事前準備

公式サイトを参考に、Flutterの環境構築を行いましょう。
https://flutter.dev/docs/get-started/install

今回はMacOS/iOSエミュレータ/VSCodeという環境で実装していきますが、Windows/Android/Intellij等の組み合わせでも特に違いはありません。

VSCodeの場合、「Shift+Command+P」でNewProjectを作成できるので、今回は「lifetimer_handson」という名前でプロジェクトを作成していきます。

スクリーンショット 2019-03-17 17.56.05.png

エミュレータが立ち上がればOKです。もし起動に失敗する場合は、環境構築に不備がある可能性が高いので、公式サイトに戻り再度インストール手順に従って設定を行いましょう。

サンプルプロジェクトの削除

Flutterでは、プロジェクト作成時にデフォルトでサンプル実装が記述されています。一から自分たちでアプリをつくっていく際には不要になるため、main.dartファイルのソースコードを全文削除し、中身を次のように変更します。

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

void main() => runApp(MaterialApp(
  theme: ThemeData.dark(),
  home: Container()
));

スクリーンショット 2019-03-17 18.04.51.png

真っ黒の画面が表示されるはずです。ここから段階的にアプリを実装してきましょう。

:one: レイアウト作成

上記の初期実装において、main.dartで表示していたのは空のContainerでした。今回はここに新たなページを埋め込んでいきます。

まずは新しいディレクトリとファイルを作成します。main.dartと同じ階層にpages/lifetimer.dartを作成していきましょう。

pages/lifetimer.dart
import 'package:flutter/material.dart';

class LifeTimerPage extends StatefulWidget {
  @override
  _LifeTimerPageState createState() => _LifeTimerPageState();
}

class _LifeTimerPageState extends State<LifeTimerPage> {
  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

pages/lifetimer.dartでも空の空のContainerを返していますが、今回はStatefulWidgetという状態を持つことができるクラスでラップしています。

:pencil: Stateful or Stateless widgets?

このファイルをmain.dartで使用します。importは以下のように記述することができます。

main.dart
import 'package:flutter/material.dart';
import './pages/lifetimer.dart';

void main() => runApp(MaterialApp(
  theme: ThemeData.dark(),
  home: LifeTimerPage(), // 修正
));

homeとしてLifeTimerPage()を呼び出すようにしておきます。もちろんこの状態では空のContainerが返るため画面は黒い状態のままです。

画面の大枠を作成

マテリアルデザインで基本的なレイアウトを作成するため、Scaffoldを利用して空のContainerを置き換えます。

pages/lifetimer.dartを以下のように書き換えてください。

pages/lifetimer.dart
import 'package:flutter/material.dart';

class LifeTimerPage extends StatefulWidget {
  @override
  _LifeTimerPageState createState() => _LifeTimerPageState();
}

class _LifeTimerPageState extends State<LifeTimerPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(  // 修正
        appBar: AppBar(title: Text("LifeTimer")), 
        body: Text("LifeTimerBody"));
  }
}

スクリーンショット 2019-03-17 18.19.47.png

画面にレイアウトが表示されました。

リスト形式による表示

また、ListViewを使用することで、テキストを縦に複数表示することが可能です。

:pencil: Layouts in Flutter

pages/lifetimer.dart
      body: ListView(
        children: <Widget>[
          Text("LifeTimerBody"),
          Text("LifeTimerBody"),
          Text("LifeTimerBody")
        ],
      ),

スクリーンショット 2019-03-17 18.26.21.png

ちなみに、ListViewはスクロール機能も備えているため、この画面で縦にスクロールするとにゅるにゅるとした画面スクロールを体感することができます。

さらに、PaddingでこのListViewをラップすることにより、CSSでいうところのpaddingを追加できます。

Paddingは前述したContainerでも同様の機能を実装できます。

:pencil: Flutter — Container Cheat Sheet

pages/lifetimer.dart
      body: Padding(
        padding: EdgeInsets.all(16.0),
        child: ListView(
          children: <Widget>[
            Text("LifeTimerBody"),
            Text("LifeTimerBody"),
            Text("LifeTimerBody")
          ],
        ),
      ),

スクリーンショット 2019-03-17 18.31.45.png

これにより、左上の空間に少し余白が生まれました。

タイトルの作成

画面ボディ部の一番先頭にタイトルを表示していきます。先ほど作成したbodyを以下のように置き換えます。

pages/lifetimer.dart
      body: Padding(
        padding: EdgeInsets.all(16.0),
        child: ListView(
          children: <Widget>[
            _buildTitle(), // 修正
            Text("LifeTimerBody"),
            Text("LifeTimerBody")
          ],
        ),
      ),

_buildTitle()はWidgetを返す関数です。bodyの呼び出し元と同じ_LifeTimerPageStateクラス内で、以下の関数を作成します。

pages/lifetimer.dart

  Widget _buildTitle() {
    return Padding(
      padding: EdgeInsets.only(bottom: 10.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Icon(Icons.mood),
          SizedBox(
            width: 5.0,
          ),
          Text(
            'あなたに残された日数を計算します',
            style: TextStyle(fontSize: 18.0),
          ),
        ],
      ),
    );
  }

Paddingは前述したContainerでラップしているのは前回と同じです。今回はchildRowというWidgetを使用しています。これは、複数のWidgetを横並びで配置するためです。MainAxisAlignment.centerを指定することで、childrenに定義されているIconSizedBoxTextを中央揃えで配置することができます。TextではTextStyleを使用することでフォントサイズやフォントファミリーを指定することが可能です。

:pencil: Flutter Layout Cheat Sheet

また、 PaddingではEdgeInsetsを使用することでpaddingの指定が可能であることは前述の通りですが、今回はonlyを使用することで、bottomにのみpaddingを適用しています。

スクリーンショット 2019-03-17 18.54.44.png

実行することで、先頭のテキストが書き換わっていることを確認できます。

:two: 日付入力と表示

Flutterで日時を入力する方法は複数存在しますが、今回はDateTimePickerFormFieldというライブラリを利用していきましょう。ライブラリを利用するためにはpubspec.yamlで対象のバージョンを記述する必要があります。

pubspec.yaml
...
dependencies:
  flutter:
    sdk: flutter

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^0.1.2
  datetime_picker_formfield: ^0.1.8 # 追加
...

pubspec.yamlで依存関係に追加したライブラリは、dartファイルから呼び出すことができるようになります。
また、日付を扱う上で欠かせないフォーマッターを利用するため、DateFormatを使用していきますが、これはintl.dartに含まれています。合わせて2つのパッケージをインポートしていきましょう。

pages/lifetimer.dart
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:datetime_picker_formfield/datetime_picker_formfield.dart';

class LifeTimerPage extends StatefulWidget {
  @override
  _LifeTimerPageState createState() => _LifeTimerPageState();
}
...

今回の追加実装ではカレンダー入力操作、入力した値の保持、入力情報の画面表示を同時に記載しています。

pages/lifetimer.dart
class _LifeTimerPageState extends State<LifeTimerPage> {
  // 日時フォーマット
  final formats = {
    InputType.both: DateFormat("yyyy-MM-dd HH:mm:ss"),
    InputType.date: DateFormat('yyyy-MM-dd'),
    InputType.time: DateFormat("HH:mm"),
  };
  DateTime birthDate;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("LifeTimer")),
      body: Padding(
        padding: EdgeInsets.all(16.0),
        child: ListView(
          children: <Widget>[
            _buildTitle(),
            _buildBirthDateInputField(),
            _buildBirthTextField()
          ],
        ),
      ),
    );
  }

  Widget _buildTitle() {
    return Padding(
      padding: EdgeInsets.only(bottom: 10.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Icon(Icons.mood),
          SizedBox(
            width: 5.0,
          ),
          Text(
            'あなたに残された日数を計算します',
            style: TextStyle(fontSize: 18.0),
          ),
        ],
      ),
    );
  }

  Widget _buildBirthDateInputField() {
    return Padding(
      padding: EdgeInsets.symmetric(vertical: 10.0),
      child: DateTimePickerFormField(
        inputType: InputType.date,
        format: formats[InputType.date],
        editable: true,
        decoration: InputDecoration(
            labelText: '生年月日を入力してください', hasFloatingPlaceholder: false),
        onChanged: (birthDate) => _setBirthDate(birthDate),
      ),
    );
  }

  void _setBirthDate(DateTime _birthDate) {
    setState(() {
      birthDate = _birthDate;
      print(birthDate);
    });
  }

  Widget _buildBirthTextField() {
    return Padding(
      padding: EdgeInsets.all(5.0),
      child: Text("生年月日: ${_getBirthDate()}"),
    );
  }

  String _getBirthDate() {
    return birthDate != null
        ? formats[InputType.date].format(birthDate)
        : '入力待ち';
  }
}

_buildBirthDateInputField()では、DateTimePickerFormFieldでカレンダーの入力を促しています。カレンダーがモーダルで表示されるライブラリとなっており、日付の指定後にonChangedが発火します。onChangedの中では_setBirthDate関数が呼ばれていて、Stateを継承した_LifeTimerPageStateクラスに存在するプロパティbirthDateを書き換えます。この時setState()を使用しなければならないことに注意してください。

最後に、_buildBirthTextField()を呼び出すことで、入力したカレンダーの日付が画面に出力されることが確認できます。

[日付入力時]
スクリーンショット 2019-03-17 19.13.44.png

[日付入力後]
スクリーンショット 2019-03-17 19.14.06.png

現在日時のカウントアップ

DateTimeTimerを利用することで、現在時刻を取得し画面に表示します。Timerを使用するために、dart:asyncをインポートします。

pages/lifetimer.dart
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:datetime_picker_formfield/datetime_picker_formfield.dart';
import 'dart:async'; // 追加

class LifeTimerPage extends StatefulWidget {
  @override
  _LifeTimerPageState createState() => _LifeTimerPageState();
}

class _LifeTimerPageState extends State<LifeTimerPage> {
  // 日時フォーマット
  final formats = {
    InputType.both: DateFormat("yyyy-MM-dd HH:mm:ss"),
    InputType.date: DateFormat('yyyy-MM-dd'),
    InputType.time: DateFormat("HH:mm"),
  };
  // 日時
  DateTime birthDate;
  DateTime now; // 追加

  // 追加
  @override
  void initState() {
    super.initState();
    Timer.periodic(Duration(seconds: 1), (timer) {
      setState(() {
        now = new DateTime.now();
      });
    });
  }

今回はWidgetが作成される時に呼ばれるinitState()の中で、1秒ごとに現在時刻を更新するTimerを仕込みます。

:pencil: Stateful Widget Lifecycle

画面に現在時刻を表示するWidgetの追加実装は以下の通りです。

pages/lifetimer.dart
...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("LifeTimer")),
      body: Padding(
        padding: EdgeInsets.all(16.0),
        child: ListView(
          children: <Widget>[
            _buildTitle(),
            _buildBirthDateInputField(),
            _buildBirthTextField(),
            _buildNowTextField() // 追加
          ],
        ),
      ),
    );
  }
...
  // 追加
  Widget _buildNowTextField() {
    return Padding(
      padding: EdgeInsets.all(5.0),
      child: Text("現在時刻: ${_getNow()}"),
    );
  }
  // 追加
  String _getNow() {
    return now != null ? formats[InputType.both].format(now) : "なし";
  }

スクリーンショット 2019-03-17 19.32.20.png

無事画面に表示されていることが確認できました。

:three: 残余名の計算

ここからは残りの寿命を計算するロジックを追加してきます。

平均寿命から算出される残余命時間の計算式

日本人の平均寿命は83.98歳です。(2016年度調査)

厳密には異なりますが、仮に1年を365日として83年を計算すると、

83*365日=30295日

0.98年は357日16時間48分

とみなすことで、

生まれてから30652日16時間48分後が推定命日

と言い換えることができます。

この計算式を実装していきましょう。以前に作成した_setBirthDateを削除し、以下のように_setBirthAndExpectedDeathDateへと変更して、birthDate同様expectedDeathDateを更新します。

pages/lifetimer.dart
...
  // 日時
  DateTime birthDate;
  DateTime now;
  DateTime expectedDeathDate; // 追加
...
  Widget _buildBirthDateInputField() {
    return Padding(
      padding: EdgeInsets.symmetric(vertical: 10.0),
      child: DateTimePickerFormField(
        inputType: InputType.date,
        format: formats[InputType.date],
        editable: true,
        decoration: InputDecoration(
            labelText: '生年月日を入力してください', hasFloatingPlaceholder: false),
        onChanged: (birthDate) => _setBirthAndExpectedDeathDate(birthDate), // 修正
      ),
    );
  }

  void _setBirthAndExpectedDeathDate(DateTime _birthDate) { // 修正
    // var averageDeathAge = 83.98; // year: 83, day: 357, hour: 16, minute: 48
    var averageDaethDuration = Duration(days: 30652, hours: 16, minutes: 48);
    setState(() {
      birthDate = _birthDate;
      expectedDeathDate = birthDate.add(averageDaethDuration);
    });
  }
...
  // 追加
  Widget _buildExpectedDateTextField() {
    return Padding(
      padding: EdgeInsets.all(30.0),
      child: Column(
        children: <Widget>[
          Text("平均寿命から計算されるあなたの推定命日"),
          SizedBox(
            height: 5.0,
          ),
          Text(
            "${_getExpectedDeathDate()}",
            style: TextStyle(fontSize: 30.0, color: Colors.red[300]),
          ),
        ],
      ),
    );
  }

  // 追加
  String _getExpectedDeathDate() {
    return expectedDeathDate != null
        ? formats[InputType.date].format(expectedDeathDate)
        : "入力待ち";
  }

スクリーンショット 2019-03-17 19.52.38.png

寿命パラメータの表示

推定命日がわかったので、現在時刻との差分から残りの時間をカウントダウンしていきます。日単位、時間単位、分単位、秒単位でそれぞれ計算してみましょう。

pages/lifetimer.dart
...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("LifeTimer")),
      body: Padding(
        padding: EdgeInsets.all(16.0),
        child: ListView(
          children: <Widget>[
            _buildTitle(),
            _buildBirthDateInputField(),
            _buildBirthTextField(),
            _buildNowTextField(),
            _buildExpectedDateTextField(),
            _buildLeftTimeParams() // 追加
          ],
        ),
      ),
    );
  }
...
  // 追加
  Widget _buildLeftTimeParams() {
    return Padding(
      padding: EdgeInsets.all(10.0),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Padding(
            padding: EdgeInsets.only(bottom: 10.0),
            child: Text("推定死亡日まで残り",
                style: TextStyle(decoration: TextDecoration.underline)),
          ),
          Padding(
            padding: EdgeInsets.only(left: 20.0, bottom: 3.0),
            child: Text(
              "日計算 :${_getExpectedDeathInDays()} 日",
              style: TextStyle(fontSize: 20.0),
            ),
          ),
          Padding(
            padding: EdgeInsets.only(left: 20.0, bottom: 3.0),
            child: Text(
              "時計算 :${_getExpectedDeathInHours()} 時間",
              style: TextStyle(fontSize: 20.0),
            ),
          ),
          Padding(
            padding: EdgeInsets.only(left: 20.0, bottom: 3.0),
            child: Text(
              "分計算 :${_getExpectedDeathInMinutes()} 分",
              style: TextStyle(fontSize: 20.0),
            ),
          ),
          Padding(
            padding: EdgeInsets.only(left: 20.0, bottom: 3.0),
            child: Text(
              "秒計算 :${_getExpectedDeathInSeconds()} 秒",
              style: TextStyle(fontSize: 20.0),
            ),
          ),
        ],
      ),
    );
  }

  // 追加
  String _getExpectedDeathInDays() {
    var leftTimeDuration = _calcLifeTimeDuration();
    return leftTimeDuration != null ? leftTimeDuration.inDays.toString() : "-";
  }
  // 追加
  String _getExpectedDeathInHours() {
    var leftTimeDuration = _calcLifeTimeDuration();
    return leftTimeDuration != null ? leftTimeDuration.inHours.toString() : "-";
  }
  // 追加
  String _getExpectedDeathInMinutes() {
    var leftTimeDuration = _calcLifeTimeDuration();
    return leftTimeDuration != null
        ? leftTimeDuration.inMinutes.toString()
        : "-";
  }
  // 追加
  String _getExpectedDeathInSeconds() {
    var leftTimeDuration = _calcLifeTimeDuration();
    return leftTimeDuration != null
        ? leftTimeDuration.inSeconds.toString()
        : "-";
  }
  // 追加
  Duration _calcLifeTimeDuration() {
    return expectedDeathDate != null ? expectedDeathDate.difference(now) : null;
  }

_calcLifeTimeDuration()では推定命日から現在時刻の差分をDurationとして取得していて、各パラメータごとの残り時間を表示しています。

表示結果は以下のようになります。

スクリーンショット 2019-03-17 20.00.34.png

この状態で、1秒ごとに秒計算の数値がカウントダウンされるようになります。毎秒減り続ける寿命に焦る気持ちを抑えつつ実装を進めていきましょう。

:four: グラフ表示

いよいよ大詰めです。今回は残りの寿命をパーセンテージで表していきます。

まずは表示するグラフの大きさを画面サイズから決定したいと思います。小さなデバイスでも適切なサイズで表示できるようにするためです。MediaQueryを使用して、widthの大きさを取得し、_buildRadialProgress関数に値を渡します。

pages/lifetimer.dart
...
import '../widgets/lifetimer_painter.dart';
...
  @override
  Widget build(BuildContext context) {
    double width = MediaQuery.of(context).size.width; // 
    return Scaffold(
      appBar: AppBar(title: Text("LifeTimer")),
      body: Padding(
        padding: EdgeInsets.all(16.0),
        child: ListView(
          children: <Widget>[
            _buildTitle(),
            _buildBirthDateInputField(),
            _buildBirthTextField(),
            _buildNowTextField(),
            _buildExpectedDateTextField(),
            _buildLeftTimeParams(),
            _buildRadialProgress(width), // 追加
          ],
        ),
      ),
    );
  }
...
   Widget _buildRadialProgress(double deviceWidth) {
    double circleSize = deviceWidth * 0.7;
    double textSize = deviceWidth * 0.065;
    return Container(
      padding: EdgeInsets.all(4.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          CustomPaint(
            foregroundPainter: LifeTimerPainter(
                lineColor: Colors.grey,
                completeColor: Colors.blueAccent,
                completePercent: _calcLeftTimePersent(),
                width: 8.0),
            child: Container(
              padding: EdgeInsets.all(10),
              height: circleSize,
              width: circleSize,
              child: RaisedButton(
                color: Colors.blue,
                shape: CircleBorder(),
                child: Row(
                  children: <Widget>[
                    Icon(
                      Icons.directions_run,
                      size: textSize,
                    ),
                    Text(
                      "${_getLeftTimePercentStr()}",
                      style: TextStyle(fontSize: textSize),
                    ),
                  ],
                ),
                onPressed: () {},
              ),
            ),
          ),
        ],
      ),
    );
  }
...
  String _getLeftTimePercentStr() {
    if (expectedDeathDate == null) {
      return "- %";
    }
    return _calcLeftTimePersent().toStringAsFixed(8) + "%";
  }

  double _calcLeftTimePersent() {
    if (expectedDeathDate == null) {
      return 0.0;
    }
    Duration livingDuration = expectedDeathDate.difference(now);
    Duration averageDeathDuration =
        Duration(days: 30652, hours: 16, minutes: 48);
    int livingDurationInMillis = livingDuration.inMilliseconds;
    int averageDeathDurationInMillis = averageDeathDuration.inMilliseconds;
    return livingDurationInMillis * 100 / averageDeathDurationInMillis;
  }
...

_buildRadialProgress関数ではCustomPaintを使用して図を描画しています。foregroundPainterで指定しているLifeTimerPainterCustomPainterを継承した自作クラスです。新たなフォルダとファイル名で新クラスを作成します。今回はwidgets/lifetimer_painter.dartという名前にします。以下のようにソースコードを実装してください。

widgets/lifetimer_painter.dart
import 'package:flutter/material.dart';
import 'dart:math';

class LifeTimerPainter extends CustomPainter {
  Color lineColor;
  Color completeColor;
  double completePercent;
  double width;

  LifeTimerPainter(
      {this.lineColor, this.completeColor, this.completePercent, this.width});

  @override
  void paint(Canvas canvas, Size size) {
    Paint line = Paint()
      ..color = lineColor
      ..strokeCap = StrokeCap.round
      ..style = PaintingStyle.stroke
      ..strokeWidth = width;

    Paint complete = Paint()
      ..color = completeColor
      ..strokeCap = StrokeCap.round
      ..style = PaintingStyle.stroke
      ..strokeWidth = width;

    Offset center = Offset(size.width / 2, size.height / 2);
    double radius = min(size.width / 2, size.height / 2);
    canvas.drawCircle(center, radius, line);

    double arcAngle = 2 * pi * (completePercent / 100);
    canvas.drawArc(Rect.fromCircle(center: center, radius: radius), -pi / 2,
        arcAngle, false, complete);
  }

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

以上で実装は完了です。最終的な画面は次のようになります。

スクリーンショット 2019-03-17 21.06.49.png

まとめ

基本的な機能とWidgetを中心に作成してきましたが、Flutterで頻繁に登場する多くのWidgetを利用できるハンズオンになっているのではないかと思います。今回登場したWidgetには公式ページへのリンクを記載しています。Flutterは公式サイトの解説が充実しているため、これから本格的にスマホアプリを作る際には、ぜひご一読することをおすすめします。

ここまで読んでいただきありがとうございました。:bow:

ソースコードはGitHubで公開しています。
https://github.com/shunp/lifetimer_handson

17
11
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
17
11