티스토리 뷰

지금까지 기초적인 UI를 다루는 book list 프로젝트, state를 다루는 타이머 프로젝트를 진행하며 점차 flutter와 친해지고 있는 중이다.

 

이번 포스팅에서는 예제 프로젝트계의 스테디셀러인 Todo List를 다루면서 데이터베이스와 API 연계와 같은 백단과 어떻게 연결하는지 확인해보려고 한다.

 

Todo List는 다음과 같은 화면과 기능을 가지도록 구성하려고 한다.

 

1. 화면

 - SplashScreen(초기 진입 스플래시 화면)

 - LoginScreen(로그인 화면)

 - ListScreen(홈 화면)

 

2. 기능

 - 로그인 

 - 자동 로그인

 - Todo 목록보기

 - Todo 상세보기

 - Todo 등록하기

 - Todo 삭제하기

 - Todo 수정하기

 

위 기능 목록에서 알 수 있듯, 아주 기본적인 CRUD 기능만 구현해볼 것이다 두가지 버전으로 나누어 첫 번째 버전에서는 SQLite로 데이터를 관리하고, 두 번째 버전에서 Firebase를 사용하여 좀 더 간단하게 구성해보려고 한다.

 

바로 들어가보자.


우선 main.dart는 바로 초기 진입 스플레시 페이지로 이동하도록 설정해준다.

 

◎main.dart

import 'package:flutter/material.dart';
import 'package:todo_list/screens/slplash_screen.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Todo List',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: SplashScreen()
    );
  }
}

Splash Screen은 SharedPreferences를 이용하여 최초 로그인이면 로그인 페이지로, 이미 로그인을 한 상태라면 TodoList로 이동할 수 있도록 구성해준다. 

 

SharedPreferences는 아주 간단하거나 적은 양의 데이터를 기기에 저장하고 꺼낼 때 사용할 수 있는 패키지이다. shared-prefs는 키-값 방식으로 데이터를 저장하며, 해당 값은 앱의 데이터를 삭제할 때까지 유지되는 특징이 있다. 

 

이번 프로젝트에서 사용할 외부 패키지는 아래와 같이 shared-prefs를 포함하여 3가지이다.

 

◎pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  
  # 아이콘 사용을 위한 패키지
  cupertino_icons: ^1.0.2 

  # shared-prefs 를 사용하기 위한 패키지
  shared_preferences: ^2.0.6
  
  # SQLite 
  sqflite: ^2.0.0+3

 

◎splash_screen.dart

import 'dart:async';

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

import 'list_screen.dart';
import 'login_screen.dart';

class SplashScreen extends StatefulWidget {
  @override
  _SplashScreenState createState() => _SplashScreenState();
}

class _SplashScreenState extends State<SplashScreen> {

  // 데이터를 가져오는 시간만큼 Timer 설정
  @override
  void initState() {
    super.initState();
    Timer(Duration(seconds: 2), () {
      moveScreen();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('SplashScreen', style: TextStyle(fontSize: 20)),
            Text('나만의 일정 관리: TODO 리스트 앱', style: TextStyle(fontSize: 20)),
          ],
        ),
      )
    );
  }

  // isLogin 이 true 이면 List, false 라면 Login Screen 으로 이동
  void moveScreen() async{
    await checkLogin().then((isLogin) {
      if(isLogin) {
        Navigator.of(context).pushReplacement(MaterialPageRoute(builder:
          (context) => ListScreen()
        ));
      } else {
         Navigator.of(context).pushReplacement(MaterialPageRoute(builder:
          (context) => LoginScreen()
         ));
      }
    });
  }

  // shared-prefs 에서 isLogin 의 값을 확인 후 true/false 반환
  Future<bool> checkLogin() async{
    SharedPreferences prefs = await SharedPreferences.getInstance();
    // ?? => null check
    bool isLogin = prefs.getBool('isLogin') ?? false;
    print("[*] isLogin : " + isLogin.toString());

    return isLogin;
  }
}

최초의 shared-prefs는 아무런 데이터를 가지고 있지 않기 때문에, 스플래시 이후에 바로 로그인 페이지로 이동하게 된다.

 

◎login_screen.dart

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

import 'list_screen.dart';

class LoginScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('로그인')
      ),
      body: Container(
        padding: EdgeInsets.fromLTRB(20, 0, 20, 0),
        width: MediaQuery.of(context).size.width * 0.85,
        child: ElevatedButton(
          onPressed: () {
            setLogin().then((_) {
              Navigator.of(context).pushReplacement(
                MaterialPageRoute(builder: (context) => ListScreen())
              );
            });
          },
          child: Text('로그인')
        )
      )
    );
  }
  
  // shared preferences 에 값 setting
  Future setLogin() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    prefs.setBool('isLogin', true);
  }
}

로그인 화면은 크게 어려운 부분은 없다. 로그인 버튼을 하나 생성하고, 버튼을 클릭하면 shared-prefs에 값을 세팅한 뒤에  todoList화면으로 넘어간다.


이제 본격적으로  TodoList화면을 확인해보려고 하는데, 그전에 DB와 연동하여 기본적인 CRUD를 구성하는 만큼 model객체와 쿼리를 작성할 repository 객체를 먼저 확인해보자.

 

◎todo.dart (model)

class Todo {
  late int? id;
  late String title;
  late String description;
 
  // constructor
  Todo({
    this.id,
    required this.title,
    required this.description
  });
 
  // SQLite 쿼리가 Map 형태로 Data를 다루기 때문에 model -> map으로 데이터 변환을 위한 메서드
  Map<String, dynamic> toMap() {
    return {
      'id' : id,
      'title' : title,
      'description' : description
    };
  }
 
  // SQLite 쿼리가 Map 형태로 Data를 다루기 때문에 map -> model으로 데이터 변환을 위한 메서드
  // fromMap은 Model instance가 생성되기 전에 메서드를 호출하기 때문에 아래와 같이 static으로 사용할 수 있도록 선언한다.
  Todo.fromMap(Map<dynamic, dynamic>? map) {
    id = map?['id'];
    title = map?['title'];
    description = map?['description'];
  }

}

 

◎todo_sqlite.dart


import 'package:sqflite/sqflite.dart';

import '../models/todo.dart';

class TodoSqlite {
  late Database db;

  // DB 초기화 -> 테이블 생성
  Future initDb() async {
    db = await openDatabase('my_db.db');
    await db.execute(
      'CREATE TABLE IF NOT EXISTS MyTodo (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, description TEXT)'
    );
  }

  // Todo List 반환
  Future<List<Todo>> getTodos() async {
    List<Todo> todos = [];
    List<Map> maps = await db.query('MyTodo', columns: ['id', 'title', 'description']);
    maps.forEach((element) {
      todos.add(Todo.fromMap(element));
    });
    return todos;
  }
 
  // Todo 반환
  Future<Todo?> getTodo(int id) async {
    Todo todo = Todo(title: 'Temp', description: 'Temp');
    List<Map> map = await db.query('MyTodo',
      columns: ['id', 'title', 'description'],
      where: 'id = ?',
      whereArgs: [id]
    );
    if (map.isNotEmpty) {
      return Todo.fromMap(map[0]);
    }
  }
  
  // Todo 추가
  Future addTodo(Todo todo) async {
    int id = await db.insert('MyTodo', todo.toMap());
  }

  // Todo 삭제
  Future deleteTodo(int id) async {
    await db.delete('MyTodo', where: 'id = ?', whereArgs: [id]);
  }

  // Todo 수정
  Future updateTodo(Todo todo) async {
    await db.update('MyTodo', todo.toMap(), where: 'id = ?', whereArgs: [todo.id]);
  }


}

 repository도 딱히 어렵거나 새로운 내용은 없지만, 위 코드에서 알 수 있듯 Database 클래스의 query 메서드는 개발자 편의를 위하여 쿼리를 모두 작성하는 것이 아닌, 테이블, 칼럼, where절, 인자를 파라미터로 받아서 실행해주며, insert와 delete, update 메서드를 모두 따로 가지고 있다.  

 

SQLite와 관련된 내용은 안드로이드 Native를 다루며 한번 확인했으니 따로 설명하지는 않겠다.

 

주의할 점은 DB와 연동되는 모든 Query는 비동기로 처리해줘야 한다는 것이다.


◎list_screen.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:todo_list/%20providers/todo_default.dart';
import 'package:todo_list/%20providers/todo_sqlite.dart';

import '../models/todo.dart';

class ListScreen extends StatefulWidget {
  @override
  _ListScreenState createState() => _ListScreenState();
}

class _ListScreenState extends State<ListScreen> {

  // 화면에 표시하기 위한 Todo 배열
  List<Todo> todos = [];

  // 데이터를 가져오는 시간을 표현하기 위한 로딩
  bool isLoading = true;

  // Repository
  TodoSqlite todoSqlite = TodoSqlite();

  // 최초 진입 시 Todo List 를 보여주는 init 메서드
  Future initDb() async {
    await todoSqlite.initDb().then((value) async{
      todos = await todoSqlite.getTodos();
    });
  }

  // DB에서 데이터를 가져오는 시간을 임의로 표시해주기 위하여 Timer로 2초의 딜레이를 생성함
  @override
  void initState() {
    super.initState();
    Timer(Duration(seconds: 2), () {
      initDb().then((_) {
        setState(() {
          isLoading = false;
        });
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('할 일 목록'),
        actions: [
          // 탭했을 때, 잉크가 퍼져나가는 이펙트를 가진 영역
          InkWell(
            onTap: () {},
            child: Container(
              padding: EdgeInsets.all(5),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                // Open API 실습을 위한 뉴스 아이콘
                children: [
                  Icon(Icons.book),
                  Text('뉴스')
                ],
              ),
            )
          )
        ],
      ),
      // Todo 추가를 위한 Floating Button
      floatingActionButton: FloatingActionButton(
        child: Text('+', style: TextStyle(fontSize: 25)),
        onPressed: () {
          // Dialog창을 띄우기 위한 함수 context, builder, action을 인자로 가진다.
          // context: 알림창을 띄우는 스크린
          // builder: 알림창 설정 후 반환함
          // action: 알림참에서 버튼을 클릭 시 이벤트를 지정
          showDialog(
              context: context,
              builder: (BuildContext context) {
                String title = '';
                String description = '';
                return AlertDialog(
                  title: Text('할 일 추가하기'),
                  content: Container(
                    height: 200,
                    child: Column(
                      children: [
                        // 입력 영역, 변경이 생기면 바로 그 값을 title, description에 저장
                        TextField(
                          onChanged: (value) {
                            title = value;
                          },
                          decoration: InputDecoration(labelText: '제목'),
                        ),
                        TextField(
                          onChanged: (value) {
                            description = value;
                          },
                          decoration: InputDecoration(labelText: '설명'),
                        )
                      ],
                    )
                  ),
                  actions: [
                    TextButton(
                        child: Text('추가'),
                        onPressed: () async {
                          await todoSqlite.addTodo(
                            Todo(title: title, description: description)
                          );
                          List<Todo> newTodos = await todoSqlite.getTodos();
                          setState(() {
                            todos = newTodos;
                          });
                          Navigator.of(context).pop();
                        },
                    ),
                    TextButton(
                         child: Text('취소'),
                        onPressed: () {
                          Navigator.of(context).pop();
                        },

                    )
                  ],
                );
              }
          );
        },
      ),
      // TodoList를 표시하기 위한 Body - ListView
      body:
        // isLoading 이 true인 2초 동안 프로그래스바를 띄우기 위함, isLoading이 false로 변경되면 ListView 를 띄운다.
        isLoading ? Center(
          child: CircularProgressIndicator(),
        ) :
        ListView.separated(
          itemCount: todos.length,
          itemBuilder: (context, index) {
            return ListTile(
              title: Text(todos[index].title),
              onTap: () {
                showDialog(
                    context: context,
                    builder: (BuildContext context) {
                      return SimpleDialog(
                        title:Text('할 일'),
                        children: [
                          Container(
                            padding: EdgeInsets.all(10),
                            child: Text('제목: ' + todos[index].title),
                          ),
                          Container(
                            padding: EdgeInsets.all(10),
                            child: Text('설명: ' + todos[index].description),
                          )
                        ],
                      );
                    }
                );
              },
              // trailing 아이콘을 놓기 위한 영역
              trailing: Container(
                width: 80,
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.end,
                  children: [
                    // 수정 아이콘을 위한 Container
                    Container(
                        padding: EdgeInsets.all(5),
                        child: InkWell(
                            child: Icon(Icons.edit),
                            onTap: () {
                              showDialog(
                                  context: context,
                                  builder: (BuildContext context) {
                                    String title = todos[index].title;
                                    String description = todos[index].description;

                                    return AlertDialog(
                                      title: Text('할 일 수정하기'),
                                      content: Container(
                                        height: 200,
                                        child: Column(
                                          children: [
                                            TextField(
                                              onChanged: (value) {
                                                title = value;
                                              },
                                              decoration: InputDecoration(
                                                hintText: todos[index].title
                                              ),
                                            ),
                                            TextField(
                                              onChanged: (value) {
                                                description = value;
                                              },
                                              decoration: InputDecoration(
                                                  hintText: todos[index].description
                                              ),
                                            ),
                                          ],
                                        ),
                                      ),
                                      actions: [
                                        TextButton(
                                            onPressed: () async{
                                              Todo newTodo = Todo(
                                                id: todos[index].id,
                                                title: title,
                                                description: description
                                              );
                                              await todoSqlite.updateTodo(newTodo);
                                              List<Todo> newTodos = await todoSqlite.getTodos();
                                              setState(() {
                                                todos = newTodos;
                                              });
                                              Navigator.of(context).pop();
                                            },
                                            child: Text('수정')
                                        ),
                                        TextButton(
                                            onPressed: () {
                                              Navigator.of(context).pop();
                                            },
                                            child: Text('취소')
                                        )
                                      ],
                                    );
                                  }
                              );
                            }
                        )
                    ),
                    // 삭제 아이콘을 위한 Container
                    Container(
                      padding: EdgeInsets.all(5),
                      child: InkWell(
                        child: Icon(Icons.delete),
                        onTap: () async {
                          showDialog(
                              context: context,
                              builder: (BuildContext context) {
                                return AlertDialog(
                                  title: Text('할 일 삭제하기'),
                                  content: Container(
                                    child: Text('삭제하시겠습니까?')
                                  ),
                                  actions: [
                                    TextButton(
                                      child: Text('삭제'),
                                      onPressed: () async {
                                        await todoSqlite.deleteTodo(todos[index].id ?? 0);
                                        List<Todo> newTodos = await todoSqlite.getTodos();
                                        setState(() {
                                          todos = newTodos;
                                        });
                                        Navigator.of(context).pop();
                                      },
                                    ),
                                    TextButton(
                                        onPressed: () {
                                          Navigator.of(context).pop();
                                        },
                                        child: Text('취소')
                                    )
                                  ],
                                );
                              }
                          );
                        }
                      )
                    )
                  ],
                ),
              ),
            );
          },
          // ListView의 Separator
          separatorBuilder: (context, index) {
            return Divider();
          },
        )
    );
  }
}

모든 Dialog와 버튼 등의 UI 컴포넌트가 한 코드에 들어가 있기 때문에 코드가 다소 길지만, UI 컴포넌트 선언 뒤에 그 역할에 맞는 repository 함수를 실행해주는 것뿐이다.

 

주석을 따라 하나하나 확인해보면 그리 어려운 코드는 아니다. 

 

여기까지 모두 작성을 했으면, 이제 바로 어플을 실행하여 확인해보자.


아주 만족스럽게 동작하는 CRUD Todo List이다. 

 

다음 포스팅은 Firebase의 DB를 사용하여 동일한 프로젝트를 구성해보려고 한다.

 

그럼 끝!

Comments