소소한 개발 공부

[Flutter] 메모 앱 만들기 본문

개발/앱 개발

[Flutter] 메모 앱 만들기

이내내 2022. 2. 19. 08:45

메모를 작성하고 저장하는 어플리케이션을 만든다.

우선 패키지를 만들고 스크립트를 작성한다.

막상 패키지를 만들려고 lib 폴더에 우클릭을 해봐도 New->Package 버튼이 보이질 않았다. 아래의 블로그 정보를 통해 해결했다.

lib 폴더 우클릭 -> Mark Directory as -> Source Root 클릭 하면 lib 폴더에 우클릭 시 New->Package 버튼이 나온다.

https://ondolroom.tistory.com/865

 

flutter package 생성이 안될 경우

플러터 프로젝트를 생성하고 lib폴더에서 패키지를 생성하려고 할 때 아래와 같이 패키지가 보이지 않는 경우가 있다. 필자의 경우 툴박스의 안드로이드 스튜디오를 사용 할 때 보이지 않았다.

ondolroom.tistory.com

 

sqlite를 활용해 메모 저장 관리를 한다.

먼저 SQLite 데이터베이스를 사용하기 위해 sqflite path 패키지를 추가한다.

(pubspec.yaml 파일의 dependencies: 밑에 sqflite: 와 path: 를 추가)

그 후 스크립트 상단에 알림창 같은게 뜰 건데 Get dependencies와 update dependencies를 눌러준다.(이 부분은 캡처를 못했다..)

Messages 창에서 여러 로그가 뜬 후 패키지가 추가된다.

 

이 메모 앱은 모든 메모를 스크롤뷰로 볼 수 있는 home 과 메모를 쓸 수 있는 write, 특정 메모를 볼 수 있는 view, 메모를 수정할 수 있는 edit으로 구성되어 있다.

화면은 다음과 같다.

home(좌), write(우)
view(좌), edit(우)

main.dart

더보기
import 'package:flutter/material.dart';
import 'screens/home.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Memo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        primaryColor: Colors.white,
        backgroundColor: Colors.blueGrey
      ),
      home: MyHomePage(title: 'Memo'),
    );
  }
}

home.dart가 MyHomepage 메서드를 가지고 있으며 title을 required 변수로 요구하기 때문에 home:에서 MyHomePage를 호출할 때 title: 'Memo'로 title 변수를 넘겨준다.

 

home.dart

더보기
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'write.dart';
import 'package:memo_memo/database/memo.dart';
import 'package:memo_memo/database/db.dart';
import 'package:memo_memo/screens//view.dart';

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  String deleteId = '';

  @override
  Widget build(BuildContext context) {
    // BuildContext : 특정 위젯이 빌드되는 컨텍스트,
    return Scaffold(
      body: Column(
        children: <Widget>[
          Padding(
            padding: EdgeInsets.only(left: 20, top: 40, bottom: 20),
            child: Container(
              child: Text('메모메모',
                  style: TextStyle(fontSize: 36, color: Colors.blue)),
              alignment: Alignment.centerLeft,
            ),
          ),
          Expanded(child: memoBuilder(context))
        ],
      ),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: () {
          Navigator.push(
                  // navigator.push는 새 페이지가 위로 올라오게 함
                  context, // 기존 위젯의 context를 넘겨줄 수 있다.
                  CupertinoPageRoute(builder: (context) => WritePage()))
              .then((value) {
            // Navigator로 띄운 창에 대한 결과를 setState
            // 저장화면에서 돌아오면 변경사항을 다시 그리기
            // Future/async/await 처리 안해도 됨.
            // 리턴시 처리 필요없음 return Future.value(true);
            setState(() {});
          });
        },
        tooltip: '메모를 추가하려면 클릭하세요',
        label: Text('메모 추가'),
        icon: Icon(Icons.add),
      ),
    );
  }

  Future<List<Memo>> loadMemo() async {
    DBHelper sd = DBHelper();
    return await sd.memos();
  }

  Future<void> deleteMemo(String id) async {
    DBHelper sd = DBHelper();
    await sd.deleteMemo(id);
  }

  void showAlertDialog(BuildContext context) async {
    await showDialog(
      context: context,
      barrierDismissible: false, // user must tap button!
      builder: (BuildContext context) {
        return AlertDialog(
          title: Text('삭제 경고'),
          content: Text("정말 삭제하시겠습니까?\n삭제된 메모는 복구되지 않습니다."),
          actions: <Widget>[
            TextButton(
              child: Text('삭제'),
              onPressed: () {
                Navigator.pop(context, "삭제");
                setState(() {
                  deleteMemo(deleteId);
                  deleteId = '';
                });
              },
            ),
            TextButton(
              // 'FlatButton' is deprecated and shouldn't be used. Use TextButton instead.
              child: Text('취소'),
              onPressed: () {
                deleteId = '';
                Navigator.pop(context, "취소");
              },
            ),
          ],
        );
      },
    );
  }

  Widget memoBuilder(BuildContext parentContext) {
    return FutureBuilder(
      builder: (context, snap) {
        // The getter 'length' isn't defined for the type 'Object'
        // https://stackoverflow.com/questions/67945429/the-getter-length-isnt-defined-for-the-type-object
        // if (snap.data == null || snap.data!.isEmpty) {
        List<Memo> memos = snap.data as List<Memo>;
        if (memos.length == 0) {
          return Container(
            alignment: Alignment.bottomCenter,
            child: Text(
              '지금 바로 "메모 추가" 버튼을 눌러\n새로운 메모를 추가해보세요!\n\n\n\n\n\n\n\n\n',
              style: TextStyle(fontSize: 18, color: Colors.blueAccent),
              textAlign: TextAlign.center,
            ),
          );
        }
        return ListView.builder(
          physics: BouncingScrollPhysics(),
          padding: EdgeInsets.all(20),
          // itemCount: snap.data!.length,
          itemCount: memos.length,
          itemBuilder: (context, index) {
            // Memo memo = (snap.data as Map)[index];
            Memo memo = memos[index];
            // The operator '[]' isn't defined for the class 'Object'. Dart
            // https://stackoverflow.com/questions/60245865/the-operator-isnt-defined-for-the-class-object-dart
            return InkWell(
                // Container에서 onTap을 가능하게 하기
                onTap: () {
                  Navigator.push(
                      parentContext,
                      CupertinoPageRoute(
                          builder: (parentContext) =>
                              ViewPage(id: memo.id))).then((value) {
                    setState(() {});
                  });
                },
                onLongPress: () {
                  // setState(() {
                  deleteId = memo.id;
                  showAlertDialog(parentContext);
                  // }
                  // );
                },
                child: Container(
                  margin: EdgeInsets.all(5),
                  padding: EdgeInsets.all(15),
                  alignment: Alignment.center,
                  height: 100,
                  child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      crossAxisAlignment: CrossAxisAlignment.stretch,
                      children: <Widget>[
                        Column(
                          mainAxisAlignment: MainAxisAlignment.center,
                          crossAxisAlignment: CrossAxisAlignment.stretch,
                          children: <Widget>[
                            Text(
                              memo.title,
                              style: TextStyle(
                                  fontSize: 20, fontWeight: FontWeight.w500),
                              overflow:
                                  TextOverflow.ellipsis, // overflow를 ... 으로 생략
                            ),
                            Text(
                              memo.text,
                              style: TextStyle(fontSize: 15),
                              overflow: TextOverflow.ellipsis,
                            )
                          ],
                        ),
                        Column(
                          mainAxisAlignment: MainAxisAlignment.center,
                          crossAxisAlignment: CrossAxisAlignment.stretch,
                          children: <Widget>[
                            Text(
                              "최종 수정 시간: " + memo.editTime.split('.')[0],
                              style: TextStyle(fontSize: 11),
                              textAlign: TextAlign.end,
                              overflow: TextOverflow.ellipsis,
                            ),
                          ],
                        )
                      ]),
                  decoration: BoxDecoration(
                    color: Colors.white,
                    border: Border.all(
                      color: Colors.blue,
                      width: 1,
                    ),
                    boxShadow: [
                      BoxShadow(color: Colors.lightBlue, blurRadius: 3)
                    ],
                    borderRadius: BorderRadius.circular(12),
                  ),
                ));
          },
        );
      },
      future: loadMemo(),
    );
  }
}

stateful 위젯 안에서 setState를 통해 화면을 변화시킬 수 있다.

Button 종류의 onPressed: (){ setState((){}); } 로 사용한다.

 

 

Navigator.push 로 새로운 페이지를 가져올 수 있으며 새로운 페이지로 인해 기존의 페이지를 업데이트 시키고 싶다면 Navigator.push(~).then((value) { setState(() {} ); }) 로 표현한다.

https://unsungit.tistory.com/10

 

[Flutter] Bloc, Stream - setState 로 구현

원본 영상(www.youtube.com/watch?v=2iWJRAcEsaQ&list=PLwUg6hFuXV86arSYNF9x_5Vm_lKdIBpf9&index=53) main.dart import 'package:bloc_stream/src/random_list.dart'; import 'package:flutter/material.dart'; v..

unsungit.tistory.com

 

AlertDialog를 통해 앱에 알림창 혹은 경고창을 띄울 수 있다. 예제 코드 안에서는 삭제에 대한 알림창으로 사용하였다.

https://here4you.tistory.com/176

 

Flutter Example - AlertDialog

import 'package:flutter/material.dart'; class AlertDialogDemo extends StatelessWidget { // Global Key of Scaffold final scaffoldKey = GlobalKey (); @override Widget build(BuildContext context) { ret..

here4you.tistory.com

 

BoxDecoration을 통해 Container를 꾸밀 수 있다.

https://api.flutter.dev/flutter/painting/BoxDecoration-class.html

 

BoxDecoration class - painting library - Dart API

An immutable description of how to paint a box. The BoxDecoration class provides a variety of ways to draw a box. The box has a border, a body, and may cast a boxShadow. The shape of the box can be a circle or a rectangle. If it is a rectangle, then the bo

api.flutter.dev

 

write.dart

더보기
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:memo_memo/database/memo.dart';
import 'package:memo_memo/database/db.dart';
import 'package:crypto/crypto.dart';

class WritePage extends StatelessWidget {
  String title = '';
  String text = '';
  BuildContext? _context;

  @override
  Widget build(BuildContext context) {
    _context = context;
    return Scaffold(
        resizeToAvoidBottomInset: false,
        appBar: AppBar(
          actions: <Widget>[
            IconButton(
              icon: const Icon(Icons.save),
              onPressed: saveDB,
            )
          ],
        ),
        body: Padding(
          padding: EdgeInsets.all(20),
          child: Column(
            children: <Widget>[
              TextField(
                maxLines: 2,
                onChanged: (String title) {
                  this.title = title;
                },
                style: TextStyle(fontSize: 30, fontWeight: FontWeight.w500),
                //obscureText: true,
                decoration: InputDecoration(
                  //border: OutlineInputBorder(),
                  hintText: '메모의 제목을 적어주세요.',
                ),
              ),
              Padding(padding: EdgeInsets.all(10)),
              TextField(
                maxLines: 8,
                onChanged: (String text) {
                  this.text = text;
                },
                //obscureText: true,
                decoration: InputDecoration(
                  //border: OutlineInputBorder(),
                  hintText: '메모의 내용을 적어주세요.',
                ),
              ),
            ],
          ),
        ));
  }

  Future<void> saveDB() async {
    DBHelper sd = DBHelper();

    var fido = Memo(
      id: str2Sha512(DateTime.now().toString()), // String
      title: this.title,
      text: this.text,
      createTime: DateTime.now().toString(),
      editTime: DateTime.now().toString(),
    );

    await sd.insertMemo(fido);

    print(await sd.memos());
    Navigator.pop(_context!);
  }

  String str2Sha512(String text) {
    var bytes = utf8.encode(text); // data being hashed
    var digest = sha512.convert(bytes);
    return digest.toString();
  }
}

resizeToAvoidBottomInset: false 

resizeToAvoidBottomInset는 Scaffold의 body나 floating button이 가려지는 것을 막기위해 스스로 크기를 조절하고 모두 보이게 할지를 결정하는 프로퍼티(속성)로 Default값은 true이고, InputField 등을 터치함으로써 키보드가 활성화되면  Scaffold의 body가 밀려서 화면을 조절할 공간이 없어져 overflow가 발생하기 때문에 false로 지정해준다.

 

maxLines: 8, 최대 줄 수를 정한다.

obscureText: true, 비밀번호처럼 숫자를 별표로 표시한다.

 

view.dart

더보기
import 'dart:ui';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:memo_memo/database/memo.dart';
import 'package:memo_memo/database/db.dart';
import 'edit.dart';

class ViewPage extends StatefulWidget {
  ViewPage({Key? key, required this.id}) : super(key: key);

  final String id;
  @override
  _ViewPageState createState() => _ViewPageState();
}

class _ViewPageState extends State<ViewPage> {
  BuildContext? _context;
  @override
  Widget build(BuildContext context) {
    _context = context;
    return Scaffold(
        appBar: AppBar(
          actions: <Widget>[
            IconButton(
              icon: const Icon(Icons.delete),
              onPressed: showAlertDialog,
            ),
            IconButton(
              icon: const Icon(Icons.edit),
              onPressed: () {
                setState(() {
                  Navigator.push(
                          context,
                          CupertinoPageRoute(
                              builder: (context) => EditPage(id: widget.id)))
                      .then((value) {
                    setState(() {});
                  });
                });
              },
            )
          ],
        ),
        body: Padding(padding: EdgeInsets.all(20), child: loadBuilder()));
  }

  Future<List<Memo>> loadMemo(String id) async {
    DBHelper sd = DBHelper();
    return await sd.findMemo(id);
  }

  loadBuilder() {
    return FutureBuilder<List<Memo>>(
      future: loadMemo(widget.id),
      builder: (BuildContext context, AsyncSnapshot<List<Memo>> snapshot) {
        List<Memo> memos = snapshot.data as List<Memo>;
        // if (snapshot.data == null || snapshot.data == []) {
        if (memos.length == 0) {
          return Container(child: Text("데이터를 불러올 수 없습니다."));
        } else {
          // Memo memo = (snapshot.data as Map)[0];
          Memo memo = memos[0];
          return Column(
            // 보여주고자 하는 메모 내용
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              Container(
                height: 70,
                child: SingleChildScrollView(
                  child: Text(
                    memo.title,
                    style: TextStyle(fontSize: 30, fontWeight: FontWeight.w500),
                  ),
                ),
              ),
              Text(
                "메모 만든 시간:" + memo.createTime.split('.')[0],
                style: TextStyle(fontSize: 11),
                textAlign: TextAlign.end,
              ),
              Text(
                "메모 수정 시간: " + memo.editTime.split('.')[0],
                style: TextStyle(fontSize: 11),
                textAlign: TextAlign.end,
              ),
              Padding(padding: EdgeInsets.all(10)),
              Expanded(child: SingleChildScrollView(child: Text(memo.text)))
            ],
          );
        }
      },
    );
  }

  Future<void> deleteMemo(String id) async {
    DBHelper sd = DBHelper();
    await sd.deleteMemo(id);
  }

  void showAlertDialog() async {
    await showDialog(
      context: _context!,
      barrierDismissible: false, // user must tap button!
      builder: (BuildContext context) {
        return AlertDialog(
          title: Text('삭제 경고'),
          content: Text("정말 삭제하시겠습니까?\n삭제된 메모는 복구되지 않습니다."),
          actions: <Widget>[
            TextButton(
              child: Text('삭제'),
              onPressed: () {
                Navigator.pop(context, "삭제");
                deleteMemo(widget.id);
                Navigator.pop(_context!);
              },
            ),
            TextButton(
              child: Text('취소'),
              onPressed: () {
                Navigator.pop(context, "취소");
              },
            ),
          ],
        );
      },
    );
  }
}

edit.dart

더보기
import 'package:flutter/material.dart';
import 'package:memo_memo/database/memo.dart';
import 'package:memo_memo/database/db.dart';

class EditPage extends StatefulWidget {
  EditPage({Key? key, required this.id}) : super(key: key);
  final String id;

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

class _EditPageState extends State<EditPage> {
  BuildContext? _context;

  String title = '';
  String text = '';
  String createTime = '';

  @override
  Widget build(BuildContext context) {
    _context = context;
    return Scaffold(
        resizeToAvoidBottomInset: false,
        appBar: AppBar(
          actions: <Widget>[
            IconButton(
              icon: const Icon(Icons.save),
              onPressed: updateDB,
            )
          ],
        ),
        body: Padding(padding: EdgeInsets.all(20), child: loadBuilder()));
  }

  Future<List<Memo>> loadMemo(String id) async {
    DBHelper sd = DBHelper();
    return await sd.findMemo(id);
  }

  loadBuilder() {
    return FutureBuilder<List<Memo>>(
      future: loadMemo(widget.id),
      builder: (BuildContext context, AsyncSnapshot<List<Memo>> snapshot) {
        List<Memo> memos = snapshot.data as List<Memo>;
        // if (snapshot.data == null || snapshot.data == []) {
        if (memos.length == 0) {
          return Container(child: Text("데이터를 불러올 수 없습니다."));
        } else {
          // Memo memo = (snapshot.data as Map)[0];
          Memo memo = memos[0];
          var tecTitle = TextEditingController();
          title = memo.title;
          tecTitle.text = title;

          var tecText = TextEditingController();
          text = memo.text;
          tecText.text = text;

          createTime = memo.createTime;

          return Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              TextField(
                controller: tecTitle,
                maxLines: 2,
                onChanged: (String title) {
                  this.title = title;
                },
                style: TextStyle(fontSize: 30, fontWeight: FontWeight.w500),
                //obscureText: true,
                decoration: InputDecoration(
                  //border: OutlineInputBorder(),
                  hintText: '메모의 제목을 적어주세요.',
                ),
              ),
              Padding(padding: EdgeInsets.all(10)),
              TextField(
                controller: tecText,
                maxLines: 8,
                onChanged: (String text) {
                  this.text = text;
                },
                //obscureText: true,
                decoration: InputDecoration(
                  //border: OutlineInputBorder(),
                  hintText: '메모의 내용을 적어주세요.',
                ),
              ),
            ],
          );
        }
      },
    );
  }

  void updateDB() async {
    DBHelper sd = DBHelper();

    var fido = Memo(
      id: widget.id, // String
      title: this.title,
      text: this.text,
      createTime: this.createTime,
      editTime: DateTime.now().toString(),
    );

    await sd.updateMemo(fido);
    Navigator.pop(_context!);
  }
}

memo.dart

더보기
class Memo {
  final String id;
  final String title;
  final String text;
  final String createTime;
  final String editTime;

  Memo({required this.id, required this.title, required this.text, required this.createTime, required this.editTime});

  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'title': title,
      'text': text,
      'createTime': createTime,
      'editTime': editTime,
    };
  }

  // 각 memo 정보를 보기 쉽도록 print 문을 사용하여 toString을 구현
  @override
  String toString() {
    return 'Memo{id: $id, title: $title, text: $text, createTime: $createTime, editTime: $editTime}';
  }
}

db.dart

더보기
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:memo_memo/database/memo.dart';

final String tableName = 'memos';

class DBHelper {
  var _db;

  Future<Database> get database async {
    if (_db != null) return _db;
    _db = openDatabase(
      // 데이터베이스 경로를 지정합니다. 참고: `path` 패키지의 `join` 함수를 사용하는 것이
      // 각 플랫폼 별로 경로가 제대로 생성됐는지 보장할 수 있는 가장 좋은 방법입니다.
      join(await getDatabasesPath(), 'memos.db'),
      // 데이터베이스가 처음 생성될 때, dog를 저장하기 위한 테이블을 생성합니다.
      onCreate: (db, version) {
        return db.execute(
          "CREATE TABLE memos(id TEXT PRIMARY KEY, title TEXT, text TEXT, createTime TEXT, editTime TEXT)",
        );
      },
      // 버전을 설정하세요. onCreate 함수에서 수행되며 데이터베이스 업그레이드와 다운그레이드를
      // 수행하기 위한 경로를 제공합니다.
      version: 1,
    );
    return _db;
  }

  Future<void> insertMemo(Memo memo) async {
    final db = await database;

    // Memo를 올바른 테이블에 추가하세요. 또한
    // `conflictAlgorithm`을 명시할 것입니다. 본 예제에서는
    // 만약 동일한 memo가 여러번 추가되면, 이전 데이터를 덮어쓸 것입니다.
    await db.insert(
      tableName,
      memo.toMap(),
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

  Future<List<Memo>> memos() async {
    final db = await database;

    // 모든 Memo를 얻기 위해 테이블에 질의합니다.
    final List<Map<String, dynamic>> maps = await db.query('memos');

    // List<Map<String, dynamic>를 List<Memo>으로 변환합니다.
    return List.generate(maps.length, (i) {
      return Memo(
        id: maps[i]['id'],
        title: maps[i]['title'],
        text: maps[i]['text'],
        createTime: maps[i]['createTime'],
        editTime: maps[i]['editTime'],
      );
    });
  }

  Future<void> updateMemo(Memo memo) async {
    final db = await database;

    // 주어진 Memo를 수정합니다.
    await db.update(
      tableName,
      memo.toMap(),
      // Memo의 id가 일치하는 지 확인합니다.
      where: "id = ?",
      // Memo의 id를 whereArg로 넘겨 SQL injection을 방지합니다.
      whereArgs: [memo.id],
    );
  }

  Future<void> deleteMemo(String id) async {
    final db = await database;

    // 데이터베이스에서 Memo를 삭제합니다.
    await db.delete(
      tableName,
      // 특정 memo를 제거하기 위해 `where` 절을 사용하세요
      where: "id = ?",
      // Memo의 id를 where의 인자로 넘겨 SQL injection을 방지합니다.
      whereArgs: [id],
    );
  }

  Future<List<Memo>> findMemo(String id) async {
    final db = await database;

    // 모든 Memo를 얻기 위해 테이블에 질의합니다.
    final List<Map<String, dynamic>> maps =
    await db.query('memos', where: 'id = ?', whereArgs: [id]);

    // List<Map<String, dynamic>를 List<Memo>으로 변환합니다.
    return List.generate(maps.length, (i) {
      return Memo(
        id: maps[i]['id'],
        title: maps[i]['title'],
        text: maps[i]['text'],
        createTime: maps[i]['createTime'],
        editTime: maps[i]['editTime'],
      );
    });
  }
}

 

에뮬레이터를 사용하다 데이터를 지워야하겠다면

AVD Manager에서 에뮬레이터의 가장 오른쪽 버튼 -> Wipe Data를 클릭해 데이터를 지워준다.

 

 

참고 및 출처 :

입문자를 위한 플러터(Flutter) 튜토리얼 - 재즐보프 / flutter docs