Flutterラボの

プレミアム会員になる

【Flutter入門】初心者でもコピペで学べる|スケジュール管理アプリを作ってみる① -UI編-

2020.11.09

はじめに

スケジュール管理アプリの開発を①~④の項目に分けて記事にしています。

開発題材となっているアプリは『プロカレンダー』というアプリで、実際に AppStore と Google Play Store でリリースしているので、そちらをインストールして使ってみてもらえると、より完成形がわかりやすくなると思います。

画像6

iOS: https://apps.apple.com/jp/app/id1533570808
Android: https://play.google.com/store/apps/details?id=net.hatchout.pro_calendar

事前準備

Flutterでアプリ開発するための環境構築が必要です。
まだご自身のPCの環境構築が終わっていない場合は、以下の記事を参考にしてみてください。

+ボタンを押すと数字が1増えるアプリが実行できていれば、環境構築は完了です。

画像2

今回のアプリ開発では、プロジェクトフォルダ直下にある pubspec.yaml ファイル と、main.dart があるlibフォルダ内を編集します。

画像2

この記事の完成形

画像3

コードを整理する

まずは、pubspec.yaml と main.dart の不要なコードを消して整理します。
コメントアウトで書いている英語を読むとFlutterについての理解は深まると思いますが、今回は割愛します。

整理が終わると、以下のコードのように、短く簡潔になりました。

main.dart

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return MaterialApp(
     title: 'Flutter Demo',
     theme: ThemeData(
       primarySwatch: Colors.blue,
       visualDensity: VisualDensity.adaptivePlatformDensity,
     ),
     home: MyHomePage(title: 'Flutter Demo Home Page'),
   );
 }
}

class MyHomePage extends StatefulWidget {
 MyHomePage({Key key, this.title}) : super(key: key);
 final String title;

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

class _MyHomePageState extends State<MyHomePage> {
 int _counter = 0;

 void _incrementCounter() {
   setState(() {
     _counter++;
   });
 }

 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(
       title: Text(widget.title),
     ),
     body: Center(
       child: Column(
         mainAxisAlignment: MainAxisAlignment.center,
         children: <Widget>[
           Text(
             'You have pushed the button this many times:',
           ),
           Text(
             '$_counter',
             style: Theme.of(context).textTheme.headline4,
           ),
         ],
       ),
     ),
     floatingActionButton: FloatingActionButton(
       onPressed: _incrementCounter,
       tooltip: 'Increment',
       child: Icon(Icons.add),
     ),
   );
 }
}

pubspec.yaml

name: pro_calendar
description: A new Flutter application.

publish_to: 'none'

version: 1.0.0+1

environment:
 sdk: ">=2.7.0 <3.0.0"

dependencies:
 flutter:
   sdk: flutter

 cupertino_icons: ^0.1.3

dev_dependencies:
 flutter_test:
   sdk: flutter

flutter:

 uses-material-design: true

ここからが本格的な開発になります。

AppBar(画面上部のバー)の文字を変更する

画像4

appBar: AppBar(
 title: Text(widget.title), // ここを変更する
),

Textウィジェットの中身を変更します。

appBar: AppBar(
 title: Text('プロカレ'),
),

Dartでは、文字列(String)型の指定に『'(シングルクォーテーション)』『"(ダブルクォーテーション)』を両方使うことができますが、Flutter公式が『'』を使っているので、私も『'』を採用にしています。

新しいクラスを作成する

スケジュール情報を定義するクラスを作成します。

スケジュール情報は、スケジュールを識別するための『id』、題名である『title』、開始時間『startTime』、終了時間『endTime』があるので、これを定義するクラスを作成しておきます。

class Schedule {

 String title;
 DateTime startTime;
 DateTime endTime;

 Schedule({
   this.title,
   this.startTime,
   this.endTime,
 });

}

フォルダを作成する

コード量が増えてきたので、ここからはフォルダ管理します。

私の場合は、画面に関するファイルは『pages』、先ほど作成したような情報をまとめるようなクラスのあるファイルは『models』、よく使うメソッド集は『utils』というフォルダを作成して管理し、main.dart にはアプリの起動に関する情報だけ残すようにしています。

画像5

先ほどのスケジュール情報を定義するクラスを models/schedule.dart、MyHomePageクラスをTopPageクラスに名前を変えて pages/top_page.dartファイルを作成して保存するようにします。

リスト表示する

事前の準備として、DateTime型の情報をString型に変換するメソッドを使うために『intl』パッケージを取得します。

cupertino_icons: ^0.1.3
intl: 0.16.1 # この行を追記

※パッケージを取得するときは、『Pub get』する必要がありますのでお忘れなく。
パッケージの使い方に関する記事も書いているのでリンクを下に貼っておきます。

/utils/general.dartファイルを作成し、よく使いそうな変数を定義しておきます。

class General {
 static List<String> weekName = ['月', '火', '水', '木', '金', '土', '日'];
 static DateTime selectedDate;
}

今回のUI構成で編集するのは、top_page.dart内の、Scaffoldウィジェットのbodyプロパティです。
まずは、テスト情報を_TopPageStateクラス内に作成しておきます。

List<Schedule> _scheduleList = [
 Schedule(
   title: 'テスト1',
   startTime: DateTime(2020, 9, 25, 10, 00),
   endTime: DateTime(2020, 9, 25, 12, 00),
 ),
 Schedule(

   title: 'テスト2',
   startTime: DateTime(2020, 9, 25, 12, 00),
   endTime: DateTime(2020, 9, 25, 14, 00),
 ),
 Schedule(
   id: 2,
   title: 'テスト3',
   startTime: DateTime(2020, 9, 25, 14, 00),
   endTime: DateTime(2020, 9, 25, 16, 00),
 ),
];

このテスト情報をListView.builderを使ってリスト表示します。

body: ListView.builder(
 itemCount: _scheduleList.length,
 itemBuilder: (context, i) {
   return Container(
     height: 80.0,
     child: InkWell(
       child: Row(
         children: [
           Padding(
             padding: const EdgeInsets.all(8.0),
             child: Text('${DateFormat('M/d').format(_scheduleList[i].startTime)}'),
           ),
           Padding(
             padding: const EdgeInsets.all(8.0),
             child: Column(
               mainAxisAlignment: MainAxisAlignment.center,
               children: [
                 Text('${DateFormat('HH:mm').format(_scheduleList[i].startTime)}'),
                 Text('${DateFormat('HH:mm').format(_scheduleList[i].endTime)}')
               ],
             ),
           ),
           Padding(
             padding: const EdgeInsets.all(8.0),
             child: Text(_scheduleList[i].title),
           ),
         ],
       ),
     ),
   );
 }
),

ボトムナビゲーションを作成する

ボトムナビゲーションを作成するとき、Flutterでは標準で BottomNavigationBarウィジェットが用意されているのでそれを使用しても構いませんが、今回は後でいろいろカスタマイズできるように自作したいと思います。

ボトムナビゲーションのUIを返すメソッドを作成します。

Widget buildBottomNavigation() {
 return SafeArea(
   top: false,
   child: Container(
     color: Theme.of(context).primaryColor,
     height: 60.0,
     child: Row(
       mainAxisAlignment: MainAxisAlignment.end,
       children: [
         IconButton(
           icon: Icon(Icons.add, color: Colors.white,),
           onPressed: () {
           }
         )
       ],
     ),
   ),
 );
}

先ほどリスト表示で編集した、Scaffoldウィジェットのbodyプロパティを、このボトムナビゲーションを表示するようなコードに修正します。

body: Column(
 children: [
   Expanded(
     child: ListView.builder(
       itemCount: _scheduleList.length,
       itemBuilder: (context, i) {
         return Container(
           height: 80.0,
           child: InkWell(
             child: Row(
               children: [
                 Padding(
                   padding: const EdgeInsets.all(8.0),
                   child: Text('${DateFormat('M/d').format(_scheduleList[i].startTime)}'),
                 ),
                 Padding(
                   padding: const EdgeInsets.all(8.0),
                   child: Column(
                     mainAxisAlignment: MainAxisAlignment.center,
                     children: [
                       Text('${DateFormat('HH:mm').format(_scheduleList[i].startTime)}'),
                       Text('${DateFormat('HH:mm').format(_scheduleList[i].endTime)}')
                     ],
                   ),
                 ),
                 Padding(
                   padding: const EdgeInsets.all(8.0),
                   child: Text(_scheduleList[i].title),
                 ),
                 BottomNavigationBar()
               ],
             ),
           ),
         );
       }
     ),
   ),
   buildBottomNavigation(),
 ],
),

スケジュール追加ダイアログを作成する

ダイアログ自体のUIを返すメソッドを作成します。

Widget buildAddScheduleDialog(BuildContext context) {
 return StatefulBuilder(
   builder: (context, setState) => SimpleDialog(
     contentPadding: EdgeInsets.all(0.0),
     titlePadding: EdgeInsets.all(0.0),
     title: Column(
       children: [
         Container(
           padding: EdgeInsets.symmetric(horizontal: 5.0),
           height: 40.0,
           color: Theme.of(context).primaryColor,
           child: Row(
             children: [
               GestureDetector(
                 child: Icon(Icons.cancel, color: Colors.white.withOpacity(0.8),),
                 onTap: () {
                   selectedEndTime = null;
                   Navigator.pop(context);
                 },
               ),
               Padding(padding: EdgeInsets.symmetric(horizontal: 3.0)),
               Expanded(
                   child: TextField(
                     style: TextStyle(fontSize: 18.0, color: Colors.white),
                     maxLines: 1,
                     controller: titleController,
                     cursorColor: Colors.white,
                     decoration: InputDecoration(
                         contentPadding: EdgeInsets.only(bottom: 7.0),
                         hintText: 'タイトルを入力...',
                         hintStyle: TextStyle(
                             fontSize: 15.0,
                             color: Colors.white.withOpacity(0.8)
                         ),
                         border: InputBorder.none
                     ),
                   )
               ),
               GestureDetector(
                 child: Icon(Icons.check_circle, color: (!isValidationOk()) ? Colors.white.withOpacity(0.5) : Colors.white,),
                 onTap: () async {
                   if(!isValidationOk()) {
                     return;
                   }
                   _scheduleList.add(
                     Schedule(
                       title: titleController.text == '' ? 'タイトルなし' : titleController.text,
                       startTime: selectedStartTime,
                       endTime: selectedEndTime,
                     )
                   );
                   selectedEndTime = null;
                   Navigator.pop(context);
                 },
               ),
             ],
           ),
         ),
         Container(
           height: 150.0,
           child: Row(
             children: [
               Expanded(
                 child: InkWell(
                   child: Column(
                     mainAxisAlignment: MainAxisAlignment.center,
                     crossAxisAlignment: CrossAxisAlignment.center,
                     children: [
                       Text('${DateFormat('yyyy').format(selectedStartTime)}'),
                       Text('${DateFormat('MM/dd').format(selectedStartTime)}(${General.weekName[selectedStartTime.weekday - 1]})'),
                       Text('${DateFormat('HH:mm').format(selectedStartTime)}'),
                     ],
                   ),
                   onTap: () async {
                     isStartType = true;
                     await showDialog(
                         context: context,
                         builder: (context) {
                           return buildSelectTimeDialog(context);
                         }
                     );
                     setState(() {});
                   },
                 ),
               ),
               Icon(Icons.keyboard_arrow_right),
               Expanded(
                 child: InkWell(
                   child: Column(
                     mainAxisAlignment: MainAxisAlignment.center,
                     crossAxisAlignment: CrossAxisAlignment.center,
                     children: [
                       Text((selectedEndTime == null) ? '----' : '${DateFormat('yyyy').format(selectedEndTime)}'),
                       Text((selectedEndTime == null) ? '--/--(-)' : '${DateFormat('MM/dd').format(selectedEndTime)}(${General.weekName[selectedEndTime.weekday - 1]})'),
                       Text((selectedEndTime == null) ? '--:--' : '${DateFormat('HH:mm').format(selectedEndTime)}'),
                     ],
                   ),
                   onTap: () async {
                     isStartType = false;
                     if(selectedEndTime == null) selectedEndTime = selectedStartTime;
                     
                     await showDialog(
                         context: context,
                         builder: (context) {
                           return buildSelectTimeDialog(context);
                         }
                     );
                     setState(() {});
                   },
                 ),
               ),
             ],
           ),
         ),
       ],
     ),
   ),
 );
}

時間を選択するダイアログのUIを返すメソッドも作成します。

Widget buildSelectTimeDialog(BuildContext context) {
 return Padding(
   padding: const EdgeInsets.only(top: 50.0),
   child: StatefulBuilder(
     builder: (context, setState) => SimpleDialog(
       contentPadding: EdgeInsets.all(0.0),
       titlePadding: EdgeInsets.all(0.0),
       title: Column(
         children: [
           Container(
             padding: EdgeInsets.symmetric(horizontal: 5.0),
             height: 40.0,
             color: Theme.of(context).primaryColor,
             child: Row(
               children: [
                 GestureDetector(
                   child: Icon(Icons.cancel, color: Colors.white.withOpacity(0.8),),
                   onTap: () {
                     Navigator.pop(context);
                   },
                 ),
                 Padding(padding: EdgeInsets.symmetric(horizontal: 3.0)),
                 Expanded(
                   child: Text('Date', textAlign: TextAlign.center, style: TextStyle(color: Colors.white),)
                 ),
                 GestureDetector(
                   child: Icon(Icons.check_circle, color: Colors.white,),
                   onTap: () {
                     Navigator.pop(context);
                   },
                 ),
               ],
             ),
           ),
           Container(
             height: 150.0,
             child: Column(
               children: [
                 Expanded(
                   child: Container(
                     child: Row(
                       children: [
                         Expanded(
                           child: InkWell(
                             child: Container(
                               color: isStartType ? Theme.of(context).primaryColor.withOpacity(0.3) : Colors.transparent,
                               child: Column(
                                 mainAxisAlignment: MainAxisAlignment.center,
                                 children: [
                                   Text('${DateFormat('yyyy/MM/dd').format(selectedStartTime)}(${General.weekName[selectedStartTime.weekday - 1]})', style: TextStyle(fontSize: 12.0),),
                                   Text('${DateFormat('HH:mm').format(selectedStartTime)}'),
                                 ],
                               ),
                             ),
                             onTap: () {
                               isStartType = true;
                               setState(() {});
                             },
                           ),
                         ),
                         Icon(Icons.keyboard_arrow_right),
                         Expanded(
                           child: InkWell(
                             child: Container(
                               color: !isStartType ? Theme.of(context).primaryColor.withOpacity(0.3) : Colors.transparent,
                               child: Column(
                                 mainAxisAlignment: MainAxisAlignment.center,
                                 children: [
                                   Text((selectedEndTime == null) ? '----/--/--(-)' : '${DateFormat('yyyy/MM/dd').format(selectedEndTime)}(${General.weekName[selectedEndTime.weekday - 1]})', style: TextStyle(fontSize: 12.0),),
                                   Text((selectedEndTime == null) ? '--:--' : '${DateFormat('HH:mm').format(selectedEndTime)}'),
                                 ],
                               ),
                             ),
                             onTap: () {
                               isStartType = false;
                               if(selectedEndTime == null) selectedEndTime = selectedStartTime;
                               setState(() {});
                             },
                           ),
                         ),
                       ],
                     ),
                   ),
                   flex: 2,
                 ),
                 Expanded(
                   child: Container(
                     color: Colors.black.withOpacity(0.05),
                     child: Row(
                       children: [
                         Expanded(
                           flex: 2,
                           child: CupertinoPicker(
                             itemExtent: 35.0,
                             children: yearOption.map((int data) {
                               return Container(
                                   alignment: Alignment.center,
                                   height: 35.0,
                                   child: Text('$data')
                               );
                             }).toList(),
                             onSelectedItemChanged: (int index) {
                               if(isStartType) {
                                 selectedStartTime = DateTime(yearOption[index], selectedStartTime.month, selectedStartTime.day, selectedStartTime.hour, selectedStartTime.minute);
                               } else {
                                 selectedEndTime = DateTime(yearOption[index], selectedEndTime.month, selectedEndTime.day, selectedEndTime.hour, selectedEndTime.minute);
                               }
                               setState(() {});
                             },
                             scrollController: FixedExtentScrollController(
                               initialItem: yearOption.indexOf(isStartType ? selectedStartTime?.year : selectedEndTime?.year),
                             ),
                           ),
                         ),
                         Expanded(
                           flex: 1,
                           child: CupertinoPicker(
                             itemExtent: 35.0,
                             children: monthOption.map((int data) {
                               return Container(
                                   alignment: Alignment.center,
                                   height: 35.0,
                                   child: Text('$data')
                               );
                             }).toList(),
                             onSelectedItemChanged: (int index) {
                               if(isStartType) {
                                 selectedStartTime = DateTime(selectedStartTime.year, monthOption[index], selectedStartTime.day, selectedStartTime.hour, selectedStartTime.minute);
                                 dayOption = buildDayOption(selectedDate: selectedStartTime);
                               } else {
                                 selectedEndTime = DateTime(selectedEndTime.year, monthOption[index], selectedEndTime.day, selectedEndTime.hour, selectedEndTime.minute);
                                 dayOption = buildDayOption(selectedDate: selectedEndTime);
                               }
                               setState(() {});
                             },
                             scrollController: FixedExtentScrollController(
                               initialItem: monthOption.indexOf(isStartType ? selectedStartTime?.month : selectedEndTime?.month),
                             ),
                           ),
                         ),
                         Expanded(
                           flex: 1,
                           child: CupertinoPicker(
                             itemExtent: 35.0,
                             children: dayOption.map((int data) {
                               return Container(
                                   alignment: Alignment.center,
                                   height: 35.0,
                                   child: Text('$data')
                               );
                             }).toList(),
                             onSelectedItemChanged: (int index) {
                               if(isStartType) {
                                 selectedStartTime = DateTime(selectedStartTime.year, selectedStartTime.month, dayOption[index], selectedStartTime.hour, selectedStartTime.minute);
                               } else {
                                 selectedEndTime = DateTime(selectedEndTime.year, selectedEndTime.month, dayOption[index], selectedEndTime.hour, selectedEndTime.minute);
                               }
                               setState(() {});
                             },
                             scrollController: FixedExtentScrollController(
                               initialItem: dayOption.indexOf(isStartType ? selectedStartTime?.day : selectedEndTime?.day),
                             ),
                           ),
                         ),
                         Expanded(
                           flex: 1,
                           child: CupertinoPicker(
                             itemExtent: 35.0,
                             children: hourOption.map((int data) {
                               return Container(
                                   alignment: Alignment.center,
                                   height: 35.0,
                                   child: Text('$data')
                               );
                             }).toList(),
                             onSelectedItemChanged: (int index) {
                               if(isStartType) {
                                 selectedStartTime = DateTime(selectedStartTime.year, selectedStartTime.month, selectedStartTime.day, hourOption[index], selectedStartTime.minute);
                               } else {
                                 selectedEndTime = DateTime(selectedEndTime.year, selectedEndTime.month, selectedEndTime.day, hourOption[index], selectedEndTime.minute);
                               }
                               setState(() {});
                             },
                             scrollController: FixedExtentScrollController(
                               initialItem: hourOption.indexOf(isStartType ? selectedStartTime?.hour : selectedEndTime?.hour),
                             ),
                           ),
                         ),
                         Expanded(
                           flex: 1,
                           child: CupertinoPicker(
                             itemExtent: 35.0,
                             children: minuteOption.map((int data) {
                               return Container(
                                   alignment: Alignment.center,
                                   height: 35.0,
                                   child: Text('$data')
                               );
                             }).toList(),
                             onSelectedItemChanged: (int index) {
                               if(isStartType) {
                                 selectedStartTime = DateTime(selectedStartTime.year, selectedStartTime.month, selectedStartTime.day, selectedStartTime.hour, minuteOption[index]);
                               } else {
                                 selectedEndTime = DateTime(selectedEndTime.year, selectedEndTime.month, selectedEndTime.day, selectedEndTime.hour, minuteOption[index]);
                               }
                               setState(() {});
                             },
                             scrollController: FixedExtentScrollController(
                               initialItem: minuteOption.indexOf(isStartType ? selectedStartTime?.minute : selectedEndTime?.minute),
                             ),
                           ),
                         ),
                       ],
                     ),
                   ),
                   flex: 3,
                 )
               ],
             ),
           ),
         ],
       ),
     ),
   ),
 );
}

終了日時が選択されていなかった場合や、開始日時より終了日時のほうが先になっている場合にはスケジュール追加しないように、条件分岐を設けます。

bool isValidationOk() {
 if(selectedEndTime == null) {
   print('終了日時が選択されていません');
   return false;
 }
 if(selectedStartTime.isAfter(selectedEndTime)) {
   print('開始日時より終了日時のほうが先になっています。');
   return false;
 }
 return true;
}

あとは、buildAddScheduleDialogを表示する処理をボトムナビゲーションの+ボタンを押したときに実行するように記述します。

IconButton(
 icon: Icon(Icons.add, color: Colors.white,),
 onPressed: () async {
   // ここから追記
   initDialogSetting();
   
   await showDialog(
     context: context,
     builder: (context) {
       return buildAddScheduleDialog(context);
     },
   );
   
   titleController.clear();
   setState(() {});
   // 追記ここまで
 }
)

これで①は完成です。

次の記事②はこのアプリの主軸でもあるカレンダーの作成に取り掛かります。

Flutterラボ
hatchoutschool
FlutterとNuxtに関する知識を発信しています! 動画で学べる学習サイト『Flutterラボ』と『Nuxtラボ』を運営 Flutterラボ:https://flutterlabo.tech/ Nuxtラボ:https://flutterlabo.tech/nuxt