Flutter

[Project] '만난지 며칠' 날짜 앱 만들기 - Chp 3. 메인화면 구성

모리선생 2023. 5. 24. 00:04
728x90

목표

만난지 며칠이 지났는지 확인할 수 있는 날짜 앱을 만들어보자. 이를 응용하여 100, 200, 300, 1년, 2주년까지 확인할 수 있도록 확장해보자.


본 내용을 전현직 마케터이자 현직 개발자 및 지망생이 개인공부를 하면서 배운 내용을 정리해놓은 글입니다. 해당내용에 있어서 내용상의 문의 사항 혹은 코드상에 수정사항이 발견이 되면 알려주시면 감사하겠습니다. 같이 고민해보고 수정할 수 있도록 하겠습니다.


기본 골격은 만들어 봤으니, 이제 조금 더 디테일한 부분들을 만들어 보려고 한다. 이번에 만들고자 했던 부분은 다음의 3가지 이다.

  1. 메인화면의 완성: 날짜 표시 컨테이너 생성, Textfield 크기 조정, Appbar내 아이콘 생성
  2. 사이드바의 생성
  3. 아이콘 클릭시 생성되는 기능 만들기

그리고 각 계산된 날짜들의 컨테이너 생성을 위해서는 파일을 따로 만들어주었다.

container_box.dart

그렇게 하고 추가된 package는 다음과 같다.

import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; // DateFormat('yyyy-MM-dd') 표시를 위한 dependencies
import 'package:date_counter/container_box.dart';

 

물론 intl를 import 하면서 pubspec.yaml에서는 해당 코드를 추가해주었다.

  intl: ^0.18.1

 

자 그럼 언제나 한국인이 좋아하는 결말을 위해서 먼저 전체 코드와 사진부터 보자.

 

메인화면 - 사이드바 - 버튼 

 

@main.dart

import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; // DateFormat('yyyy-MM-dd') 표시를 위한 dependencies
import 'package:date_counter/container_box.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: BirthdayApp(),
    );
  }
}

class BirthdayApp extends StatefulWidget {
  @override
  State<BirthdayApp> createState() => _BirthdayAppState();
}

class _BirthdayAppState extends State<BirthdayApp> {
  final _focusNode = FocusNode();
  final TextEditingController _dayController = TextEditingController();
  String _resultText0 = '';
  String _resultText100 = '';
  String _resultText200 = '';
  String _resultText300 = '';
  String _resultText365 = '';

  void calculateResult() {
    setState(() {
      try {
        final enteredDate = DateTime.parse(_dayController.text);
        final calculatedDate0 =
            enteredDate.add(const Duration(days: 0)).toLocal();
        final calculatedDate100 =
            enteredDate.add(const Duration(days: 100)).toLocal();
        final calculatedDate200 =
            enteredDate.add(const Duration(days: 200)).toLocal();
        final calculatedDate300 =
            enteredDate.add(const Duration(days: 300)).toLocal();
        final calculatedDate365 =
            enteredDate.add(const Duration(days: 365)).toLocal();

        final dateFormat = DateFormat('yyyy-MM-dd');
        _resultText0 = 'The day we met: ${dateFormat.format(calculatedDate0)}';
        _resultText100 = '100 days: ${dateFormat.format(calculatedDate100)}';
        _resultText200 = '200 days: ${dateFormat.format(calculatedDate200)}';
        _resultText300 = '300 days: ${dateFormat.format(calculatedDate300)}';
        _resultText365 = '365 days: ${dateFormat.format(calculatedDate365)}';

        _dayController.clear();
      } catch (e) {
        _resultText0 = 'Invalid date format';
        _resultText100 = 'Invalid date format';
        _resultText200 = 'Invalid date format';
        _resultText300 = 'Invalid date format';
        _resultText365 = 'Invalid date format';
      }
    });
  }

  @override
  void initState() {
    super.initState();
    _focusNode.addListener(() {
      if (_focusNode.hasFocus) {
        print('TextField on foucs');
      } else {
        print('TextField lost focus');
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('우리가 만난날'),
        leading: Builder(
          builder: (context) {
            //build method의 build Context가 아니라 Builder 위젯의 BuildContext를 사용하도록 Builder위젯을 쓴다.
            return IconButton(
              icon: Icon(Icons.list),
              onPressed: () {
                setState(() {
                  Scaffold.of(context).openDrawer();
                });
              },
            );
          },
        ),
        actions: <Widget>[
          Row(
            children: [
              IconButton(onPressed: () {}, icon: Icon(Icons.save_rounded)),
              IconButton(
                icon: Icon(Icons.power_settings_new),
                tooltip: 'power off the app',
                onPressed: () {
                  showDialog(
                    context: context,
                    builder: (_) => AlertDialog(
                      title: Text("Turn off"),
                      content: Text("Would you like to exit?"),
                      actions: [
                        ElevatedButton(
                          onPressed: () {
                            Navigator.of(context).pop();
                          },
                          child: Text("Close"),
                        )
                      ],
                    ),
                  );
                },
              ),
            ],
          )
        ],
      ),
      drawer: Drawer(
        child: Column(
          children: [
            UserAccountsDrawerHeader(
              accountName: Text('Mori'),
              accountEmail: Text('test@test.com'),
              currentAccountPicture: CircleAvatar(
                backgroundColor: Colors.white,
                child: Icon(Icons.person),
              ),
            ),
            ListTile(
              title: Text('menu1'),
              leading: Icon(Icons.radio_button_on),
              onTap: () {},
            ),
            ListTile(
              title: Text('menu2'),
              leading: Icon(Icons.radio_button_on),
              onTap: () {},
            )
          ],
        ),
      ),
      body: Center(
        child: Column(
          // mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Padding(padding: EdgeInsets.fromLTRB(0, 40, 0, 0)),
            Container(
              padding: EdgeInsets.all(8),
              width: MediaQuery.of(context).size.width * 0.4,
              decoration: BoxDecoration(
                color: Colors.blue,
                borderRadius: BorderRadius.circular(8),
              ),
              child: Column(
                children: <Widget>[
                  Text(
                    'Today is',
                    style: TextStyle(color: Colors.white),
                  ),
                  Text(
                    DateFormat('yyyy-MM-dd')
                        .format(DateTime.now().toLocal()), // DateFormat을 변경해줌

                    style: TextStyle(fontSize: 20, color: Colors.white),
                  ),
                ],
              ),
            ),
            SizedBox(
              height: 72,
            ),
            Container(
              width: MediaQuery.of(context).size.width * 0.8,
              child: TextField(
                decoration: const InputDecoration(
                  labelText: 'Enter the day',
                  hintText: 'yyyy-mm-dd',
                ),
                focusNode: _focusNode,
                controller: _dayController,
                keyboardType: TextInputType.text,
                textInputAction: TextInputAction.done,
                onSubmitted: (value) {
                  calculateResult();
                },
              ),
            ),
            SizedBox(
              height: 32,
            ),
            TextBox(text: _resultText0),
            SizedBox(height: 8),
            TextBox(text: _resultText100),
            SizedBox(height: 8),
            TextBox(text: _resultText200),
            SizedBox(height: 8),
            TextBox(text: _resultText300),
            SizedBox(height: 8),
            TextBox(text: _resultText365),
            SizedBox(
              height: 56,
            ),
            ElevatedButton(
              onPressed: calculateResult,
              child: Text('Calculate'),
              style: ElevatedButton.styleFrom(
                minimumSize: Size(MediaQuery.of(context).size.width * 0.5, 50),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
@container_box.dart

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

class TextBox extends StatelessWidget {
  final String text;

  TextBox({required this.text});

  @override
  Widget build(BuildContext context) {
    return Container(
      width: MediaQuery.of(context).size.width * 0.8,
      decoration: BoxDecoration(
        border: Border.all(color: Colors.black),
        borderRadius: BorderRadius.circular(8),
      ),
      padding: EdgeInsets.all(10),
      child: Center(
        child: Text(
          text,
          style: TextStyle(fontSize: 20),
        ),
      ),
    );
  }
}

지난 시간보다 main.dart에서는 변경된 부분도 있고 새로운 파일도 추가가 되었다.

 

그럼 지금부터 하나씩 변경된 부분을 설명해보고자한다. 생각보다 간단하다.

 

 

Main화면에서 변경이 된부분

1. 날짜에 Container를 적용

원래 기존의 화면의 경우에는 Text값만 반환되도록 하여서, 100일 혹은 1년 이후의 계산된 결과 값이 return이 되도록 하였다. 하지만 이렇게 보면 실제 사용하는 사람의 입장에서는 조금 정리가 안되어 있는 느낌을 받기도 한다. 그렇다면 각각의 return값을 어떻게 Container 안에 정리된 형태로 작성해줄 수 있을까?

이전은 이런 느낌이였다

원래는 이런 형태로 Text만 반환해주었었지만.

Text(
              _resultText100,
              style: TextStyle(fontSize: 20),
            ),
            Text(
              _resultText200,
              style: TextStyle(fontSize: 20),
            ),
            Text(
              _resultText300,
              style: TextStyle(fontSize: 20),
            ),
            Text(
              _resultText365,
              style: TextStyle(fontSize: 20),
            ),

이번에는 이렇게 변화를 시켰다.

TextBox(text: _resultText0),
            SizedBox(height: 8),
            TextBox(text: _resultText100),
            SizedBox(height: 8),
            TextBox(text: _resultText200),
            SizedBox(height: 8),
            TextBox(text: _resultText300),
            SizedBox(height: 8),
            TextBox(text: _resultText365),

SizedBox의 경우는 Container간의 간격을 만들기 위함이다. TextBox로 _resultText100을 감싼것을 볼 수 있을 것인데, 이는 TextBox라는 클래스를 별도의 파일에 생성해두어 동일한 조건을 TextBox라는 클래스로 불러오면 일괄 적용할 수 있도록 만들어 놓은 것이다. 그럼 TextBox는 어떻게 구성되어 있냐고 한다면, 다음과 같다.

class TextBox extends StatelessWidget {
  final String text;

  TextBox({required this.text});

  @override
  Widget build(BuildContext context) {
    return Container(
      width: MediaQuery.of(context).size.width * 0.8,
      decoration: BoxDecoration(
        border: Border.all(color: Colors.black),
        borderRadius: BorderRadius.circular(8),
      ),
      padding: EdgeInsets.all(10),
      child: Center(
        child: Text(
          text,
          style: TextStyle(fontSize: 20),
        ),
      ),
    );
  }
}

그렇다. 아까전에 별도로 만들어 놓은 파일이 있다고 했는데 거기에 적용될 양식을 작성을 해놓은 것이다. 보면은 MediaQuery.of(context).size.width를 사용해서 Container의 사이즈가 화면의 규격에 맞게 자동으로 조절이 되도록 만들어 놓았고 그 외 박스의 색이나 테두리 그리고 내부 글자 크기 또한 지정을 해두었다.

 

왜 이렇게 사이즈를 분리해 놓는 것일까? 음, 간단히 생각을 해보면 다음과 같다. 우리가 책을 읽을때 '페이지'라는 개념이 있다. 근데 이 페이지가 끝이 안나고 내용이 얽힌채로 적혀있다면...

어....????

읽기 싫어진다. 관리도 하기 싫어 진다. 어디서 부터 어디까지 손을 봐야할지도 감이 안잡힌다. 하지만 이렇게 스타일은 스타일 그리고 계산 부분은 계산 부분 이라고 따로 정리를 해놓으면, 관리하기도 쉽고 용이하다. 아직 초보인 나의 경우에는 어떻게 정리를 해야할지 확실히 감이 잡히지는 않지만 이런것을 하나하나 분리를 해놓다 보면 언젠가는 클린한 코드를 만들 수 있을꺼라 기대한다.

 

여튼, 이렇게 만들고 나서 Appbar내의 아이콘을 만들고 다음의 단계로 이동을 하였다.

 

 

2. 사이드바의 생성

사이드바라고 한다면 왠만한 직장인들은 잘 알 것이다. 주식 시장 앱에 있는 그것이다. 옆으로 쓰윽 슬라이딩을 하면 나오는 그 +1 Layer에 있는 그 녀석 (내 이름, 주소, 혹은 다른 메뉴들이 있는 그녀석). 맞다 그거다. 원래는 List 버튼을 누르면 다른 페이지로 이동하도록 하려고 했으나, 생각을 해보니 나는 사이드바를 단지 페이지간의 이동용으로만 만들 예정이다. 그러니 이번에는 뭐, 사이드바로 그 '연결 고리'를 만들어 보려고 한다.

 

방법은 다음과 같다.

(...)

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('우리가 만난날'),
        leading: Builder(
          builder: (context) {
            //build method의 build Context가 아니라 Builder 위젯의 BuildContext를 사용하도록 Builder위젯을 쓴다.
            return IconButton(
              icon: Icon(Icons.list),
              onPressed: () {
                setState(() {
                  Scaffold.of(context).openDrawer();
                });
              },
            );
          },
        ),

    (...)

    drawer: Drawer(
        child: Column(
          children: [
            UserAccountsDrawerHeader(
              accountName: Text('Mori'),
              accountEmail: Text('test@test.com'),
              currentAccountPicture: CircleAvatar(
                backgroundColor: Colors.white,
                child: Icon(Icons.person),
              ),
            ),
            ListTile(
              title: Text('menu1'),
              leading: Icon(Icons.radio_button_on),
              onTap: () {},
            ),
            ListTile(
              title: Text('menu2'),
              leading: Icon(Icons.radio_button_on),
              onTap: () {},
            )
          ],
        ),
      ),
      
      (...)
      
      body: Center(
      
      (...)

Scaffold로 opendrawer()를 부른다음에 drawer 함수를 만들면 끝이다. 정말 쉽다. UserAccountsDrawerHeader()는 사용자의 정보를 표현하기 위해 마련된 별도의 공간이고 ListTile은 아래 항목들을 표현하기 위함이다.

요 부분?

그럼 이로써 사이드 바도 만들었다. 여기는 아까 위에서 말했던 것 처럼 각 서비스로 이동하는 링크가 될 '연결 고리'부분이다.

 

자 이제 그럼 앱을 더 이상 사용하기 싫다면 앱을 종료해야하니 버튼을 '형식적'으로나마 만들어 본다.

 

 

3. 종료버튼 생성 (모양만)

이것도 어떻게 보면 잘 안쓰기도 한다. 왜냐면 보통 앱을 밀어서 올리는 형식으로 닫는 사람들이 많다보니 (특히, 아이폰을 사용하는 분들) 잘은 쓰지 않지만 그래도 추후에 로그인이나 로그아웃 기능을 만든다면 필요할 듯 하여 일단 넣어보았다. 넣는건 다음과 같다.

(...)

        actions: <Widget>[
          Row(
            children: [
              IconButton(onPressed: () {}, icon: Icon(Icons.save_rounded)),
              IconButton(
                icon: Icon(Icons.power_settings_new),
                tooltip: 'power off the app',
                onPressed: () {
                  showDialog(
                    context: context,
                    builder: (_) => AlertDialog(
                      title: Text("Turn off"),
                      content: Text("Would you like to exit?"),
                      actions: [
                        ElevatedButton(
                          onPressed: () {
                            Navigator.of(context).pop();
                          },
                          child: Text("Close"),
                        )
                      ],
                    ),
                  );
                },
              ),
            ],
          )
        ],
      ),
  
  (...)

leading에 위치한 power_settings_new 버튼을 누르면 이렇게 AlertDialog가 표시가 되는데 여기 내에서도 ElevatedButton을 표시하여서 앱을 닫을지 말지 결정을 할 수 있도록 하였다 (지금은 작동하지 않는다).

 

근데 여기서 중요한 점이 있다. 매우 초보적이지만 많은 초보들을 당황하게 만드는 부분 (이래서 문법 공부도 차근히 해야하는거다라는걸 다시 한번 느낀다). builder로 AlertDialog를 감싸놓아둔 것을 볼 수 있을 것이다. Widget tree의 구성을 생각해본다면 알겠지만 지금 있는 AlertDialog 부분은 build Method의 context를 받아와야하는 것이 아니라 Widget 수준의 context를 받아와야지 전체적인 수준에서 컨트롤이 가능해진다. 그렇기 때문에 Builder로 감싸서 Widget build(Buildcontext context) 수준에서 context를 찾아 그 값을 반환할 수 있도록 한다. 뭐 이렇게 저렇게 설명해도 어렵다면, 

 

'너가 찾는것은 그 위에 있으니 더 높은 곳에서 관리를 할 수 있는 힘을 얻어라' 라고 유치하게 생각해보면 될 듯하다.

 

Builder라는 것은 이렇게 쓰인다는 것을 염두해두고 내용이 더 궁금하다면 (https://api.flutter.dev/flutter/widgets/BuildContext-class.html) 공식 문서를 참고해서 보자. 모를땐 교과서라는 말도 있지 않은가.

 

여튼 오늘 내용은 여기까지, 다음엔

  • 메모 페이지 생성
  • 검색결과저장
  • 사진에 아이콘 삽입

등과 같은 내용을 진행해보겠다.

 

728x90