[플러터] 메모장 앱 개발 진행과정 - ios

Flutter 앱 개발 강의 3주차를 진행중에 있습니다. 여유만 있다면 모든 강의를 다 독파 할 수도 있겠지만 직장인이 그게 어디 쉬울까요. 주말과 야간을 이용해서 짬내서 계속 진행중에 있습니다. 내일배움카드라는 필살기가 있어서 참 좋네요

 

3주차에는 메모장 앱을 개발해보는 중입니다.  현재까지 학습한 부분을 기록하면서 복습해보는 시간으로 이 포스팅을 진행해봅니다.

 

메모장 답게 제공하는기능은 다음과 같습니다.

 

메모 리스트 조회/출력, 메모 쓰기/수정/삭제, 파일로 저장하기/불러오기 등입니다.

 

개발환경은 MacOS 상에서 안드로이드 스튜디오로 진행했습니다. 비주얼스튜디오코드가 좋다고는 하는데 아직 익숙해지지 않아서 쓰기가 불편합니다만 강사님께서 쓰는 화면을 보고 있노라니 Visual Studio Code 가 훨씬 좋아보이기는 합니다. 특히 코드 자동정렬 기능이 마음에 드네요

  

뼈대를 만들고 기능을 확장해 나가는 방식으로 개발이 진행됩니다.

패키지를 가져다 쓰면서 좀 더 확장되는 개념을 가져갑니다.

Provider 를 활용한 전체 메모 상태관리, SharedPreferences 이용한 파일로 저장, 메모 데이터 구조 json 화 등등 java나 다른 언어에서 제공하는 개념이 플러터에서 표현되는 것들을 배우면서 익숙해져 가는 과정을 거치고 있습니다.

 

memo_service.dart 파일과 main.dart 2개의 파일로 구성되어 있습니다.

Memo 객체 하나가 정의된 클래스입니다. 메모내용은 content 변수에 저장이 되며, toJson 과 fromJson을 통해 파일로 저장하기/읽어오기 기능을 구현할 수 있습니다.

 

memo_service.dart 

class Memo {
  Memo({
    required this.content,
  });

  String content;

  Map toJson() {
    return {'content': content};
  }

  factory Memo.fromJson(json) {
    return Memo(content: json['content']);
  }
}

 

 

Memo 데이터 처리를 관리하는 MemoService 클래스입니다.

메모 생성/수정/삭제, 그리고 파일로 저장 및 불러오기 기능을 가지고 있습니다.

ChangeNotifier 클래스를 상속받는데 메모 데이타 변경 시 StatefulWidget 위젯에 화면 갱신이 될 수 있도록 구독서비스를 제공해주는 기능을 갖고 있습니다.

 

notifyListeners 함수를 통해 구독중인 위젯에 화면을 갱신하라는  명령을 날려줍니다.

// Memo 데이터는 모두 여기서 관리
class MemoService extends ChangeNotifier {
  MemoService() {
    loadMemoList();
  }

  List<Memo> memoList = [
    Memo(content: '장보기 목록: 사과, 양파'), // 더미(dummy) 데이터
    Memo(content: '새 메모 4433'), // 더미(dummy) 데이터
  ];
  createMemo({required String content}) {
    Memo memo = Memo(content: content);
    memoList.add(memo);
    notifyListeners(); // Consumer<MemoService>의 builder 부분을 호출해서 화면 새로고침
    saveMemoList();
  }
  updateMemo({required int index, required String content}) {
    Memo memo = memoList[index];
    memo.content = content;
    notifyListeners();
    saveMemoList();
  }

  deleteMemo({required int index}) {
    memoList.removeAt(index);
    notifyListeners();
    saveMemoList();
  }

  saveMemoList() {
    List memoJsonList = memoList.map((memo) => memo.toJson()).toList();
    // [{"content": "1"}, {"content": "2"}]

    String jsonString = jsonEncode(memoJsonList);
    // '[{"content": "1"}, {"content": "2"}]'

    prefs.setString('memoList', jsonString);
  }

  loadMemoList() {
    String? jsonString = prefs.getString('memoList');
    // '[{"content": "1"}, {"content": "2"}]'

    if (jsonString == null) return; // null 이면 로드하지 않음

    List memoJsonList = jsonDecode(jsonString);
    // [{"content": "1"}, {"content": "2"}]

    memoList = memoJsonList.map((json) => Memo.fromJson(json)).toList();
  }
}

 

main.dart

 

파일 저장/읽기를 위한 prefs 변수를 선언합니다. late 키워드를 통해 비동기를 선언해주며 main() 함수는 async 키워드를 추가해줍니다.

SharedPreferences 객체를 통해 파일저장을 위한 변수에 인스턴스를 할당해줍니다. 

MultiProvider 도 정의해주는데 MemoService 객체에 대해 노티를 전달받는 Provider 로 할당해줍니다. 구독서비스 개념으로 상태 변화를 감지하는 대상으로 지정하는 과정입니다.

late SharedPreferences prefs;

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  prefs = await SharedPreferences.getInstance();
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => MemoService()),
      ],
      child: const MyApp(),
    ),
  );
}

 

 

메모리스트를 보여주는 화면위젯인 HomePageState 입니다. 여기서 리스트를 출력하고 리스트아이템을 클릭 시 상세페이지로 PageRoute를 진행해서 보여주며 선택된 메모의 index를 넘겨주는 과정을 수행합니다. Consumer 로 감싸서 만들어주는데 MemoService로 부터 데이터를 전달받고 CRUD를 진행하기 위함입니다.

class _HomePageState extends State<HomePage> {
  @override
  Widget build(BuildContext context) {
    print("homepage builder");
    return Consumer<MemoService>(
      builder: (context, memoService, child) {
        print("Consumer builder");
        // memoService로 부터 memoList 가져오기
        memoService.loadMemoList();
        List<Memo> memoList = memoService.memoList;
        return Scaffold(
          appBar: AppBar(
            title: Text("mymemo"),
          ),
          body: memoList.isEmpty
              ? Center(child: Text("메모를 작성해 주세요"))
              : ListView.builder(
                  itemCount: memoList.length, // memoList 개수 만큼 보여주기
                  itemBuilder: (context, index) {
                    Memo memo = memoList[index]; // index에 해당하는 memo 가져오기
                    return ListTile(
                      // 메모 고정 아이콘
                      leading: IconButton(
                        icon: Icon(CupertinoIcons.pin),
                        onPressed: () {
                          print('$memo : pin 클릭 됨');
                        },
                      ),
                      // 메모 내용 (최대 3줄까지만 보여주도록)
                      title: Text(
                        memo.content,
                        maxLines: 3,
                        overflow: TextOverflow.ellipsis,
                      ),
                      onTap: () {
                        // 아이템 클릭시

                        Navigator.push(
                          context,
                          MaterialPageRoute(
                            builder: (_) => DetailPage(
                              index: index,
                            ),
                          ),
                        );
                      },
                    );
                  },
                ),
          floatingActionButton: FloatingActionButton(
            child: Icon(Icons.add),
            onPressed: () {
              // + 버튼 클릭시 메모 생성 및 수정 페이지로 이동
              memoService.createMemo(content: '');
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (_) => DetailPage(
                    index: memoService.memoList.length - 1,
                  ),
                ),
              );
            },
          ),
        );
      }
    );
  }
}

 

 

상세보기 페이지 입니다. 상태 업데이트가 필요없는 페이지라서 StatelessWidget 를 상속받아 만들어줍니다.

메모의 index값을 전달받아서 MemoService로 부터 해당 메모를 가져와 처리하는 방식입니다.

 

context.read<MemoService> 는 1회성으로 memoService 인스턴스에 접근하는 방식입니다.

강의 진행하면서 코드리팩토링도 함께 진행해주면서 리팩토링은 이렇게 진행하는 것을 보여주기도 합니다.

// 메모 생성 및 수정 페이지
class DetailPage extends StatelessWidget {
  DetailPage({super.key, required this.index});

  final int index;
  TextEditingController contentController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    MemoService memoService = context.read<MemoService>();
    Memo memo = memoService.memoList[index];

    contentController.text = memo.content;

    return Scaffold(
      appBar: AppBar(
        actions: [
          IconButton(
            onPressed: () {
              showDeleteDialog(context, memoService);
            },
            icon: Icon(Icons.delete),
          )
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: TextField(
          controller: contentController,
          decoration: InputDecoration(
            hintText: "메모를 입력하세요",
            border: InputBorder.none,
          ),
          autofocus: true,
          maxLines: null,
          expands: true,
          keyboardType: TextInputType.multiline,
          onChanged: (value) {
            // 텍스트필드 안의 값이 변할 때
            memoService.updateMemo(index : index, content : value);
          },
        ),
      ),
    );
  }

  void showDeleteDialog(BuildContext context, MemoService memoService) {
    showDialog(
      context: context,
      builder: (context) {
        return AlertDialog(
          title: Text("정말로 삭제하시겠습니까?"),
          actions: [
            // 취소 버튼
            TextButton(
              onPressed: () {
                Navigator.pop(context);
              },
              child: Text("취소"),
            ),
            // 확인 버튼
            TextButton(
              onPressed: () {
                memoService.deleteMemo(index: index);
                Navigator.pop(context); // 팝업 닫기
                Navigator.pop(context); // HomePage 로 가기
              },
              child: Text(
                "확인",
                style: TextStyle(color: Colors.pink),
              ),
            ),
          ],
        );
      },
    );
  }
}

 

 

 

아직 다 개발된건 아니지만 3주차 진행하던 부분까지 완성된 메모장앱 화면입니다.

리스트를 보여주고 아이템 선택 시 상세페이지로 이동하면서 메모 내용을 보여줍니다.

플로팅 액션 버튼을 누르면  신규 메모를 하나 생성합니다. 

 

우측 상단의 휴지통 버튼을 누르면 삭제하겠냐는 메시지와 함께 메모를 삭제하고 네비게이터 pop을 수행하면서 리스트 화면으로 넘어갑니다.

데이터 생성,수정,삭제는 실시간으로 파일로 저장되고 있어서 앱이 종료되어도 마지막 값을 유지하게 됩니다. 

 

 

MissingPluginException(No implementation found for method getAll on channel plugins.flutter.io/shared_preferences

그리고 개발중에 만난 오류 메시지

 

코드 작성이후 대부분 Hot reload 혹은 Hot restart 를 진행해서 반영된 화면을 볼 수 있었는데 저 메시지가 나타나는 경우 앱 시뮬레이터를 완전히 종료한 후에 재실행하면 오류가 해결되었습니다.

그리고 앱시뮬레이터에서 한글이 입력이 안되더군요. 이 해결책도 곧 찾아내야 겠습니다.

 

익숙해지면 빠르게 개발이 진행될 수 있을것 같은데 익숙해지는데 3만년 걸리는 느낌입니다. 꾸준히 학습해보겠습니다.