Front-End/Flutter_Project_02_Expense Tracker

5. Udemy Flutter 강의를 통한 Project - Expense Tracker

sd4beatles 2024. 12. 13. 01:06

1. Flutter 문법

1.1 setState()

setState()는 framework에게 현재 객체의 상태가 바뀌었다는 것을 알려주는 method이다. 그렇지만, 여기에는 정말로 주의할 점이 있는데 이는 아래와 같다.

"변수의 선언 위치가 정말로 중요하다! 변수의 선언 위치에 따라서 변경이 되지 않을 수 있다" *

import 'package:flutter/material.dart';

class MyWidget extends StatefulWidget {
  const MyWidget({super.key});

  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  @override
  Widget build(BuildContext context) {

    int a=23;
    setState(){
      a=24;

    };

    print(a);


    return const Placeholder();
  }
}

위 코드를 보면서, 과연 변수 a는 24로 변수로 print될까? 정답은 아쉽게도 23으로 보인다. 이유는 setState()를 활성한 순간, flutter는 다시 build함수 영역 첫 번째부터 다시 읽기 시작하는데, 그 첫 줄이 바로 int a=23;을 읽기 때문에, a는 24가 아닌 23으로 출력된다. 이를 사전에 방지하기 위해선, 변수 선언을 build 함수 외부에서 하는 것이 좋다.

1.2 Future,async,await

이를 먼저 알아보기 전에, 일단 이러한 코드가 있다고 가정해 보자.

void main() {
  showData();
}

void showData() {
  startTask();
  String account=accessData();
  fetchData(account);


}

void startTask() {
  String info1 = "Start to request data";
  print(info1);
}

String accessData() {
  String account="";

  Duration time = Duration(seconds: 3);
  if (time.inSeconds > 2) {
    Future.delayed(time, () {
      account = "2000 원";
      print(account);
    });
  } else {
    String info2 = "Data is recieved";
    print(info2);
  }

  return account;
}


void fetchData(String account){
  String info3='잔액은 $account 입니다';
  print(info3);
}

위 코드를 자세히 살펴보면, 일단 Future.delayed 같은 경우는 기존의 코드를 띄어 넘고, 다른 code를 실행한 후에 다시 되돌아서 자신의 코드를 실행하는 것이다. 즉, 우리의 예시를 잘 살펴보면, 일단 startTask=> fetchData => accessData 순으로 실행이 되기 때문에, 아래와 같은 결과물이 나오게 된다.

이러한 Asynchronous 방법은 사실 우리가 원하는 결과물이 될 수가 없다는 점이다. 즉, Future를 사용함으로써 우리는 비동기화 방법으로 코드를 실행할 수가 있었다. 다만, 우리가 원하는 것은, accessData로부터 정보를 받아, fetchData에서 출력을 하기를 원한다는 점이다.

`

void main() {  
showData();  
}

void showData() async {  
startTask();  
String account = await accessData();  
fetchData(account);  
}

void startTask() {  
String info1 = "Start to request data";  
print(info1);  
}

Future accessData() async {  
String account="";

Duration time = Duration(seconds: 3);  
if (time.inSeconds > 2) {  
await Future.delayed(time, () {  
account = "2000 원";  
print(account);  
});  
} else {  
String info2 = "Data is recieved";  
print(info2);  
}

return account;  
}

void fetchData(String account) {  
String info3 = '잔액은 $account 입니다';  
print(info3);  
}

accessData를 Future<반환type>을 지정한 후, async와 await를 각각 지정해준다. Future로 반환되는 값, 즉 ShowData 함수의 account 또한 async와 await를 지정해주면, 우리가 원하는 대로, 값을 받은 후에 출력하게 된다.

출처: Coding Chef

2. Working with Future Data (Project)

Section 1에서 배운 내용을 토대로 아래와 같이, 선택하기 전에는 "No date selected" 명칭이 붙지만, User가 date를 입력한 후에 선택된 날짜를 화면에 보여주는 것을 배워보도록 하자.

첫번째로, async 와 await keyword를 이용해서, future data가 들어오기 전까지 기다리는 비동기함수를 만들도록 하자.

class _NewExpenseState extends State<NewExpense> {
  final _titleController = TextEditingController();
  final _amountController = TextEditingController();
  //? can allow for null values if empty
  DateTime? _selectedDate;

  void _presentDatePicker() async{
    final now=DateTime.now();
    final firstDate=DateTime(now.year-1,now.month,now.day);
    final pickDate= await showDatePicker(
      context: context,
      initialDate:now, 
      firstDate: firstDate, lastDate: now);

      setState(() {
        _selectedDate=pickDate;
      });
  }

이때, _selectedDate에 ?마크를 붙이게 되면, 이는 DateTime이 null이 될 수도 있다는 것을 명시함으로서 Debugging Error를 피할 수 있다.

두번째로, DateTime의 형식을 결정해야 하는데, 이는 우리가 이미 expense.dart에서 정의를 내렸기 때문에 이를 expense.dart에서 불러온다.

  //formatter
final formatter=DateFormat.yMd();

그리고, calender section에 null값이면 "No date selected"를 만약 값이 있다면, formatter형태로 화면에 표시하도록 하자. 이때, 주의할 점은 flutter는 _selectedData가 null인지 아닌지 판단 불가능하다. 이때, 강제적으로 _selectedDate가 null값이 아니라는 것을 강제적으로 알려주는 방법은 뒤에 ? 쓰는 것이다.


   //calender section
          Expanded(child:Row(
            mainAxisAlignment: MainAxisAlignment.end,
            crossAxisAlignment: CrossAxisAlignment.center,
            children: [
            //! sends the framework that _selectedDate can not be null.
            Text(_selectedDate==null ?"No date selected": formatter.format(_selectedDate!)),
            IconButton(
              onPressed: _presentDatePicker,
              icon: const Icon(
                Icons.calendar_month,
              ),
              ),
           ],
          ),
        ),
         ]),

3. Add DropDownMenueItem(Project)

   Row(
            children: [
              DropdownButton(
                value:_selectedCategory,
                items: Category.values
                .map((category) =>DropdownMenuItem(
                  value:category,
                  child: Text(
                    category.name.toUpperCase(),
                  ),
                  ),
                  ).toList(), 
                onChanged: (value){
                  if(value==null){
                    return;
                  }
                  setState(() {
                   _selectedCategory=value;
                  });
                }
              ),

4. Validating Input Data (Project)

User가 입력한 값이 유효한지를 판단하는 어떤 함수가 필요하다. 예를들어, expense의 title은 항상 존재해야 하며, expense의 amount는 null 값이나 0.0보다 작을 수는 없다. 이러한 모든 경우를 체크하는 함수를 만들어보도록 하자.

✍️ Flutter Note

tryParse 함수인 경우엔 numerical string인 경우는 숫자를 반환하며, 만약 숫자 문자가 아닌 경우는 null값을 반환한다.

  void _submitExpenseData(){
    //if tryParse numerical string then convert into double. if not, return null
    final enterAmount=double.tryParse(_amountController.text);
    final isValidAmount=enterAmount==null || enterAmount<0.0;
    if(_titleController.text.trim().isEmpty || isValidAmount ||_selectedDate==null ){

    }

  }

만약, 세 가지 경우(Invalid Condition)에 하나라도 충족된다면, 우리는 오류메시지를 보내야 한다. 이러한 오류 메세지 작성법은 아래와 같은데, 우리는 이번 장에선 AlertDialog()를 사용해보도록 하자.

void _submitExpenseData(){
    //if tryParse numerical string then convert into double. if not, return null
    final enterAmount=double.tryParse(_amountController.text);
    final isValidAmount=enterAmount==null || enterAmount<0.0;
    if(_titleController.text.trim().isEmpty || isValidAmount ||_selectedDate==null ){
      showDialog(context: context, 
      builder: (ctx)=>AlertDialog(
        title:const Text("Invalid Input"),
        content: const Text("Please make sure a valid title,amount,date were entered"),
        actions:[
          TextButton(
            onPressed: (){
              Navigator.pop(ctx);
            },
            child:const Text("Okay")
          )
        ]
      )
    );
    return;

    }


  }

이렇게 정의내린 함수를 활성화 시킬 수 있도록, 마지막으로 함수 이름을 집어넣어 보자.

   //3) Cancel and Save Section  
          Row(
            children: [
              TextButton(
                onPressed: () {
                  Navigator.pop(context);

                },
                child: const Text('Cancel'),
              ),
              ElevatedButton(
                onPressed:_submitExpenseData,
                child: const Text('Save Expense'),
              ),
            ],

5. Add Expense

save expense를 누르면, expense list에 추가하는 feature를 만들어 보도록 하자.
현재 State class의 submitExpenseData 함수에서 마지막 stage이다. expense_list에 저장하는 함수를 Widget에서 받아오고 싶을 때, widget keyword를 통해서, member function과 변수를 접근가능하다.

import 'package:flutter/material.dart';
import 'package:expense_tracker/models/expense.dart';

class NewExpense extends StatefulWidget {
  const NewExpense({super.key,required this.onAddExpense});


  final void Function(Expense expense) onAddExpense;

  @override
  State<NewExpense> createState() {
    return _NewExpenseState();
  }
}

class _NewExpenseState extends State<NewExpense> {
  final _titleController = TextEditingController();
  final _amountController = TextEditingController();
  //? can allow for null values if empty
  DateTime? _selectedDate;
  Category _selectedCategory=Category.leisure;


  void _presentDatePicker() async{
    final now=DateTime.now();
    final firstDate=DateTime(now.year-1,now.month,now.day);
    final pickDate= await showDatePicker(
      context: context,
      initialDate:now, 
      firstDate: firstDate, lastDate: now);

      setState(() {
        _selectedDate=pickDate;
      });
  }

  void _submitExpenseData(){
    //if tryParse numerical string then convert into double. if not, return null
    final enterAmount=double.tryParse(_amountController.text);
    final isValidAmount=enterAmount==null || enterAmount<0.0;
    if(_titleController.text.trim().isEmpty || isValidAmount ||_selectedDate==null ){
      showDialog(context: context, 
      builder: (ctx)=>AlertDialog(
        title:const Text("Invalid Input"),
        content: const Text("Please make sure a valid title,amount,date were entered"),
        actions:[
          TextButton(
            onPressed: (){
              Navigator.pop(ctx);
            },
            child:const Text("Okay")
          )
        ]
      )
    );
    return;

    }

    //this is the final stage for submitting expense
    widget.onAddExpense(Expense(
      title:_titleController.text,
      amount: enterAmount,
      date:_selectedDate!,
      category: _selectedCategory,
    ));


  }

NewExpense widget class의 추가 변경사항을 반영하기 위해서, expenses.dart를 다시 방문해서 아래와 같은 추가 사항을 집어넣어 주자.


class Expenses extends StatefulWidget{
  const Expenses({super.key});

  @override
  State<Expenses>createState(){
    return _ExpensesState();
  }
}




class _ExpensesState extends State<Expenses>{
final List<Expense>_registeredExpenses=[
  Expense(
    title:"Flutter Course",
    amount:12.22,
    date:DateTime.now(),
    category:Category.work),

  Expense(
    title:"50-60 Nights",
    amount:50,
    date:DateTime.now(),
    category:Category.leisure),
];

//add a function to show bottom sheet if (+) is clicked!
void _openAddExpenseOverlay(){
  showModalBottomSheet(
    context: context, 
    builder: (ctx)=> NewExpense(onAddExpense:_addExpense),
    );
}