코드로 우주평화

Flutter 로 간단한 노트 앱을 만들어보자 ① - 노트 쓰기/조회 기능 구현 본문

나는 이렇게 논다/Flutter 로 간단한 노트 앱을 만들어보자

Flutter 로 간단한 노트 앱을 만들어보자 ① - 노트 쓰기/조회 기능 구현

daco2020 2024. 10. 21. 13:25
반응형

안녕하세요. Flutter 에 입문하여 앱을 출시해 보고자 간단한 노트 앱을 만들고 있습니다.

 

이번 글에서는 노트를 쓰고, 노트를 조회하는 간단한 기능을 Provider 패키지를 이용하여 구현해보겠습니다.

 

먼저 구현 결과물부터 보여드리겠습니다.

 

처음에는 노트를 작성할 수 있는 노트 쓰기 화면이 나타나고, 저장을 누르면 노트 목록 화면으로 이동합니다. 이때, 휴지통 아이콘을 누르면 노트를 지울 수 있습니다.

 

 

그럼 이제 프로젝트를 시작해 봅시다!

 

 

플러터 프로젝트 생성

먼저 프로젝트를 생성해야겠죠. 처음에 프로젝트를 생성할 때 사용하는 명령어는 다음과 같습니다.

flutter create {프로젝트 이름}

 

그런데 이 경우에는 프로젝트의 도메인 설정이 `com.example.{프로젝트 이름}` 으로 표시되더라고요. 저는 이걸 따로 수정하기가 귀찮아서 다음 명령어로 프로젝트를 생성했습니다.

flutter create --org com.{도메인 이름} {프로젝트 이름}

 

이 명령어를 사용하면 `com.{도메인 이름}.{프로젝트 이름}` 으로 설정되기 때문에 번거롭게 파일을 찾아가면 수정할 필요가 없더라고요.

 

 

프로젝트 폴더 구조

플러터 프로젝트를 생성했다면 구조를 잡아보겠습니다. 플러터는 mvvm 패턴(모델, 뷰, 뷰모델)이 가장 유명한 것 같은데요. 저는 입문자라 그런지 어렵게 느껴지기도 하고 mvvm 이라는 명칭도 와닿지 않더라고요.

그래서 저에게 익숙한 view - service - repository 레이어로 폴더 구조를 나누었습니다. 큰 맥락에서 보면 mvvm 패턴과 별반 다르지 않다고 생각했고, 무엇보다 다양한 시도를 해보면서 직접 문제를 만나보고 싶은 욕구가 있었습니다. 

 

그렇게 해서 만든 폴더구조는 다음과 같습니다.

lib/
│
├── models/
│   └── note_model.dart              # 노트 데이터 모델
├── repositories/
│   └── note_repository.dart         # 노트 저장소 (데이터 처리)
├── services/
│   └── note_service.dart            # 비즈니스 로직 처리 (노트 상태 관리)
├── utils/
│   └── color_palette.dart           # 색상 팔레트 (UI 디자인 요소)
├── views/
│   ├── note_detail_view.dart        # 노트 쓰기 화면
│   ├── note_list_view.dart          # 노트 목록 화면
│   └── settings_view.dart           # 설정 화면 (이번 글에서는 빈 화면)
└── main.dart                        # 앱의 엔트리 포인트

 

 

자, 그럼 본격적으로 구현을 해볼까요~?

 

 

 

구현 1. 노트 데이터를 정의하자 (Models)

모델은 노트의 데이터 구조를 정의하는 곳입니다. 이 앱에서는 NoteModel 클래스를 사용해 노트의 id, content, createdAt 속성을 관리합니다. 또한, 이 데이터를 JSON으로 변환하거나, 반대로 JSON 데이터를 객체로 변환할 수 있는 메서드도 포함했습니다.

// lib/models/note_model.dart
class NoteModel {
  final String id;
  final String content;
  final DateTime createdAt;

  NoteModel({
    required this.id,
    required this.content,
    required this.createdAt,
  });

  // JSON 변환 메소드
  factory NoteModel.fromJson(Map<String, dynamic> json) {
    return NoteModel(
      id: json['id'],
      content: json['content'],
      createdAt: DateTime.parse(json['createdAt']),
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'content': content,
      'createdAt': createdAt.toIso8601String(),
    };
  }
}

 

 

 

구현 2. 저장소 레이어를 만들자 (Repositories)

NoteRepository는 노트 데이터를 로컬에 저장하거나 불러오는 역할을 합니다. shared_preferences라는 패키지 사용해 노트를 간단히 로컬에 저장하고, 목록을 불러오는 기능을 구현했습니다. 이곳은 실제 데이터 소스와 상호작용하는 레이어 계층입니다.

 

데이터를 저장하고 불러오기 위해, 먼저 shared_preferences 패키지를 설치합니다.

flutter pub add shared_preferences

 

설치가 끝난 다음에는 아래처럼 코드를 작성해 주세요. 기본적인 CRUD 를 구현했습니다.

// lib/repositories/note_repository.dart
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
import '../models/note_model.dart';

class NoteRepository {
  static const String _key = 'notes';

  // 저장된 모든 노트 가져오기
  Future<List<NoteModel>> fetchNotes() async {
    final prefs = await SharedPreferences.getInstance();
    final notesJson = prefs.getString(_key);
    if (notesJson != null) {
      List<dynamic> jsonList = jsonDecode(notesJson);
      return jsonList.map((json) => NoteModel.fromJson(json)).toList();
    }
    return [];
  }

  // 노트 저장하기
  Future<void> saveNote(NoteModel note) async {
    final prefs = await SharedPreferences.getInstance();
    List<NoteModel> notes = await fetchNotes();
    notes.add(note);
    final notesJson = jsonEncode(notes.map((n) => n.toJson()).toList());
    await prefs.setString(_key, notesJson);
  }

  // 노트 삭제하기
  Future<void> deleteNoteById(String id) async {
    final prefs = await SharedPreferences.getInstance();
    List<NoteModel> notes = await fetchNotes();
    notes.removeWhere((note) => note.id == id);
    final notesJson = jsonEncode(notes.map((n) => n.toJson()).toList());
    await prefs.setString(_key, notesJson);
  }
}

 

 

 

구현 3. 비즈니스 로직을 만들자 (Services)

NoteServiceChangeNotifier를 상속받아 상태 관리를 담당합니다. 이곳에서 노트를 추가, 삭제하거나 노트 목록을 가져오는 로직을 처리하고, 상태가 변경되면 UI에 자동으로 반영될 수 있도록 notifyListeners()를 호출합니다.

 

ChangeNotifier 는 flutter/foundation.dart 에서 가져와 사용합니다.

import 'package:flutter/foundation.dart';

 

extends 를 이용하면 ChangeNotifier 를 상속받을 수 있습니다. 

class NoteService extends ChangeNotifier { ...

 

Service 레이어에서는 Repository 등을 이용해 비즈니스 로직을 처리합니다. 아래 전체 코드를 보시면 Repository 를 통해 노트를 조회, 추가, 삭제합니다. 이때 상태가 변경되므로 notifyListeners() 를 호출하여 UI에 반영할 수 있도록 알립니다.

// lib/services/note_service.dart
import 'package:flutter/foundation.dart';
import '../models/note_model.dart';
import '../repositories/note_repository.dart';

class NoteService extends ChangeNotifier {
  final NoteRepository _noteRepository = NoteRepository();
  List<NoteModel> _notes = [];

  List<NoteModel> get notes => _notes;

  // 모든 노트 불러오기
  Future<void> getNotes() async {
    _notes = await _noteRepository.fetchNotes();
    notifyListeners();
  }

  // 노트 추가하기
  Future<void> addNote(String content) async {
    if (content.length > 300) {
      throw Exception('노트는 300자를 넘을 수 없습니다.');
    }
    final note = NoteModel(
      id: DateTime.now().millisecondsSinceEpoch.toString(),
      content: content,
      createdAt: DateTime.now(),
    );
    await _noteRepository.saveNote(note);
    _notes.add(note);
    notifyListeners();
  }

  // 노트 삭제하기
  Future<void> deleteNoteById(String id) async {
    await _noteRepository.deleteNoteById(id);
    _notes.removeWhere((note) => note.id == id);
    notifyListeners();
  }
}

 

 

 

구현 4. 색상 팔레트를 정의하자 (Utils)

색상 팔레트 파일은 앱 전반에 걸친 디자인 일관성을 유지하기 위한 설정입니다. 일단은 간단하게 색상 팔레트를 두었지만 더 많은 설정이 추가되면 리팩터링을 할 예정입니다.

// lib/utils/color_palette.dart
import 'package:flutter/material.dart';

class ColorPalette {
  static const Color background = Color(0xFF1C1C1E); // 배경 검정색
  static const Color noteYellow = Color(0xFFFFE599); // 노란색
  static const Color noteBlue = Color(0xFFB6E5F8); // 파란색
  static const Color textWhite = Color(0xFFFFFFFF); // 텍스트 하얀색
}

 

 

 

구현 5. 노트 쓰기 화면을 그리자 (Views)

사용자가 노트를 입력할 수 있는 화면입니다. 작성 후 저장 버튼을 누르면 NoteService를 통해 데이터를 저장하고, 저장이 완료되면 노트 목록 화면으로 이동합니다.

 

View 파일에서는 Provider 를 함께 사용합니다. 먼저 명령어로 Provider 를 설치하겠습니다.

flutter pub add provider

 

그다음 Provider 를 import 합니다.

import 'package:provider/provider.dart';

 

Provider 는 상위에서 제공된 객체를 가져와서 사용할 수 있게 도와줍니다. 예를 들어 main.dart 를 아래처럼 작성하면 NoteService 객체를 MyApp 에 주입하는 것이죠.

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => NoteService()),
      ],
      child: MyApp(),
    ),
  );
}

 

여기서 ChangeNotifierProvider는 NoteService라는 객체를 앱 전반에 제공하고, 하위 위젯에서 Provider.of 또는 Consumer를 통해 이 객체에 접근할 수 있습니다.

 

노트 쓰기 화면의 전체 코드는 아래와 같습니다. 

// lib/views/note_detail_view.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:myapp/views/note_list_view.dart';
import '../services/note_service.dart';
import '../utils/color_palette.dart';

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

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

class _NoteDetailViewState extends State<NoteDetailView> {
  final TextEditingController _controller = TextEditingController();
  int _charCount = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: ColorPalette.background,
      appBar: AppBar(
        title: Text('노트 쓰기', style: TextStyle(color: ColorPalette.textWhite)),
        backgroundColor: ColorPalette.background,
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            TextField(
              controller: _controller,
              maxLength: 300,
              onChanged: (text) {
                setState(() {
                  _charCount = text.length;
                });
              },
              style: TextStyle(color: ColorPalette.textWhite),
              decoration: InputDecoration(
                hintText: '노트를 작성하세요...',
                hintStyle: TextStyle(color: Colors.grey),
                counterText: '',
                border: InputBorder.none,
              ),
            ),
            SizedBox(height: 10),
            Text(
              '$_charCount / 300',
              style: TextStyle(color: Colors.grey),
            ),
            Spacer(),
            ElevatedButton(
              onPressed: () async {
                if (_controller.text.isNotEmpty) {
                  await Provider.of<NoteService>(context, listen: false)
                      .addNote(_controller.text);
                  // 노트를 저장한 후 NoteListView로 이동
                  Navigator.pushReplacement(
                    context,
                    MaterialPageRoute(builder: (context) => NoteListView()),
                  );
                }
              },
              child: Text('저장', style: TextStyle(color: Colors.white)),
              style: ElevatedButton.styleFrom(
                backgroundColor: Colors.blue,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

 

노트를 저장하면 Provider  주입받은 NoteService 를 통해 노트를 저장합니다.

 

이때, 옵션으로 listen: false 파라미터를 넣는데요. 이것은 상태 변화 알림을 받지 않겠다는 뜻입니다. 노트를 저장한 다음에는 Navigator 를 통해 곧바로 NoteListView(노트 목록 화면)로 이동할 것이므로 현재 화면이 새로 빌드되지 않도록 listen: false 으로 막은 것입니다.

 

 

 

구현 6. 노트 목록 화면을 그리자 (Views)

저장된 노트 목록을 조회할 수 있는 화면입니다. Provider를 사용해 상태를 구독하고, 상태 변화에 따라 UI가 자동으로 업데이트되도록 구현해 보겠습니다.

 

우선 initState() 를 통해 위젯이 처음 생성되면 노트를 불러옵니다.

  @override
  void initState() {
    super.initState();
    Provider.of<NoteService>(context, listen: false).getNotes();
  }

 

이때에도 Provider 를 사용하여 NoteService 을 호출하여 노트를 불러오는데요. 노트 쓰기 화면과 마찬가지로 listen: false 옵션을 넣습니다. 그 이유는 initState() 이 처음 생성될 때만 호출되므로 굳이 상태 변화를 구독하고 있을 필요가 없기 때문입니다.

 

대신, 상태 변화에 따른 UI 업데이트가 필요한 경우에는 이렇게 Consumer 를 사용하여 상태 알림을 받습니다.

      body: Consumer<NoteService>(
        builder: (context, noteService, child) { ...

 

Consumer 를 이용하면 상태가 변할 때 전체 위젯이 아닌, 정확히 필요한 위젯만 다시 빌드할 수 있습니다.

 

노트 목록 화면의 전체 코드는 아래와 같습니다. NoteService 로부터 노트를 가져와 ListView 를 이용해 목록을 그립니다.

// lib/views/note_list_view.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/note_service.dart';
import '../utils/color_palette.dart';

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

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

class _NoteListViewState extends State<NoteListView> {
  @override
  void initState() {
    super.initState();
    Provider.of<NoteService>(context, listen: false).getNotes();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: ColorPalette.background,
      appBar: AppBar(
        title: Text('노트 목록', style: TextStyle(color: ColorPalette.textWhite)),
        backgroundColor: ColorPalette.background,
      ),
      body: Consumer<NoteService>(
        builder: (context, noteService, child) {
          if (noteService.notes.isEmpty) {
            return Center(
              child: Text(
                '저장된 노트가 없습니다.',
                style: TextStyle(color: ColorPalette.textWhite),
              ),
            );
          }

          return ListView.builder(
            itemCount: noteService.notes.length,
            itemBuilder: (context, index) {
              final note = noteService.notes[index];
              return ListTile(
                title: Text(
                  note.content,
                  style: TextStyle(color: ColorPalette.textWhite),
                ),
                subtitle: Text(
                  note.createdAt.toString(),
                  style: TextStyle(color: Colors.grey),
                ),
                trailing: IconButton(
                  icon: Icon(Icons.delete_outline, color: Colors.red),
                  onPressed: () async {
                    await noteService.deleteNoteById(note.id);
                  },
                ),
              );
            },
          );
        },
      ),
    );
  }
}



 

 

구현 7. 앱을 실행하자 (main)

main.dart 파일은 앱을 실행하는 엔트리 포인트입니다. 여기서 MultiProvider를 사용해 NoteService를 앱의 모든 화면에서 사용할 수 있도록 설정하고, 초기 화면은 노트 쓰기 화면으로 이동하도록 해보겠습니다.

 

먼저 ChangeNotifierProvider 에 대해 설명하자면, ChangeNotifier 를 상속받은 클래스의 상태 변화가 있을 때 이를 구독하고 있는 위젯들이 자동으로 UI를 업데이트할 수 있도록 돕는 역할을 합니다.

 

앞서 우리는 ChangeNotifierNoteService 에 상속했는데요. NoteService 의 상태 변화를 구독하는 위젯인 Views 에서 알아차리고 UI를 업데이트하는 거죠.

 

적용하는 방법은 아래 코드처럼 MyApp 클래스 안에 providers 를 명시합니다.

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider<NoteService>(create: (_) => NoteService()),
      ],
      ...

 

 

main.dart 의 전체 코드는 아래와 같습니다.

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'services/note_service.dart';
import 'views/note_detail_view.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider<NoteService>(create: (_) => NoteService()),
      ],
      child: MaterialApp(
        home: NoteDetailView(),
        theme: ThemeData.dark(),
      ),
    );
  }
}

 

 

Provider 를 사용한 이유

Provider 는 가장 많이 사용하는 상태 관리 패키지 중에 하나로, 플러터에서 쉽게 상태를 관리하고 UI에 반영할 수 있게 해줍니다. 이번 노트 앱 프로젝트에서 Provider를 사용한 이유는 크게 다음 세 가지입니다.

 

1. 상태의 전역 공유: ChangeNotifierProvider로 앱 전체에서 NoteService 의 상태를 공유할 수 있습니다.

 

2. 자동 UI 업데이트: 사용자가 노트를 추가하거나 삭제할 때, notifyListeners()를 호출하여 상태 변화를 알려주고, 이를 구독하는 노트 목록 화면이 자동으로 업데이트됩니다.

 

3. 상태와 UI의 분리: NoteService는 상태 관리와 비즈니스 로직만 담당하고, UI는 이 상태를 구독하여 데이터를 표시하는 역할만 합니다.

 

한 문장으로 요약하자면 '상태를 효율적으로 관리하고 UI에 쉽게 반영하기 위함'이라고 할 수 있습니다.

 

 

 

마무리

이번 글에서는 Flutter 로 노트를 쓰고, 저장하고, 목록을 조회하는 간단한 노트 앱을 만들어보았습니다. 아래 내용을 다시 한번 확인해 주세요.

  • Provider를 사용해 상태 관리하기
  • ChangeNotifier를 통해 상태 변경을 UI에 알리기
  • shared_preferences를 사용한 로컬 데이터 저장 및 불러오기

다음 글에서는 노트 앱을 더 고도화하여 하단 메뉴바를 추가해 보겠습니다.

반응형