코드로 우주평화

Flutter로 간단한 노트 앱을 만들어보자 ④ - 노트 수정 기능 구현 본문

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

Flutter로 간단한 노트 앱을 만들어보자 ④ - 노트 수정 기능 구현

daco2020 2024. 11. 6. 19:39
반응형

이번 글에서는 이전까지 개발한 Flutter 노트 앱노트 수정 기능을 추가하는 과정을 보여드리겠습니다.

 

기존 노트 앱에서는 수정 기능이 따로 없이 쓰기와 조회, 삭제 기능만 있었습니다. 이번에는 수정 페이지를 추가하고 수정 로직까지 구현해 보도록 하겠습니다.

 

수정 기능의 결과는 다음과 같습니다.

 

- 수정 버튼을 누르면 노트 수정 화면으로 이동합니다.

- 노트 수정 화면에서 내용을 수정한 후 우측 상단의 저장 버튼을 누릅니다.

- 노트를 저장하면 노트 목록 화면으로 되돌아가며 내용이 수정된 노트가 보여집니다.

 

 

주요 구현 내용

1. Enums: 수정 페이지 이름을 정의하는 PageName Enum 을 추가합니다.
2. NoteModel: 노트 수정 시간을 나타내는 updatedAt 필드를 추가합니다.
3. NoteRepository: 노트를 가져오고 수정하는 저장소 로직을 추가합니다.
4. NoteService: 노트 수정과 상태를 알려 UI에 반영합니다.
5. 노트 수정 화면: NoteEditView에서 노트를 편집 후 저장합니다.
6. 노트 목록 화면 업데이트: 수정 버튼을 추가하여 노트 수정 화면으로 이동할 수 있도록 합니다.

7. GoRouter 설정: 수정 화면으로 이동하기 위해 /noteEdit 경로를 GoRouter에 추가합니다.

 

 

구현 1. Enums - PageName Enum 추가

먼저, PageName Enum을 추가하여 수정 화면의 라우팅 이름으로 사용합니다. 이렇게 Enum을 추가해 두면 이후 라우팅 설정에서 가독성을 높이고 유지보수가 용이합니다.

// lib/enums.dart
enum TabItem { noteDetail, noteList, settings }
enum PageName { noteEdit }  // 수정 페이지를 위한 Enum 추가

 

수정 페이지는 탭에서 사용하지 않으면 TapItem 이 아닌 PageName으로 정의합니다.

 

 

구현 2. NoteModel - updatedAt 필드 추가

노트가 수정된 시간을 저장하고 관리하기 위 NoteModel updatedAt 필드를 추가했습니다. 이 필드는 추후 업데이트 순으로 정렬하는 등 다양하게 사용할 수 있습니다.

// lib/models/note_model.dart
class NoteModel {
  final String id;
  final String content;
  final DateTime createdAt;
  final DateTime updatedAt;  // 수정된 시간 필드 추가

  NoteModel({
    required this.id,
    required this.content,
    required this.createdAt,
    required this.updatedAt,  // 수정된 시간 필드 추가
  });

  factory NoteModel.fromJson(Map<String, dynamic> json) {
    return NoteModel(
      id: json['id'],
      content: json['content'],
      createdAt: DateTime.parse(json['createdAt']),
      updatedAt: DateTime.parse(json['updatedAt']),  // 수정된 시간 필드 추가
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'content': content,
      'createdAt': createdAt.toIso8601String(),
      'updatedAt': updatedAt.toIso8601String(),  // 수정된 시간 필드 추가
    };
  }
}

 

 

구현 3. NoteRepository - 노트 수정 저장소 로직 추가

NoteRepository에서 노트를 수정할 수 있는 updateNote 메서드를 추가합니다. 이 메서드는 노트의 id를 통해 특정 노트를 찾아 내용과 수정 시간을 업데이트합니다.

// lib/repositories/note_repository.dart
class NoteRepository {

  ...
  
  // 노트 수정하기
  Future<void> updateNote(String id, String content) async {
    final prefs = await SharedPreferences.getInstance();
    List<NoteModel> notes = await fetchNotes();
    final noteToUpdate = notes.firstWhere((note) => note.id == id);
    notes[notes.indexOf(noteToUpdate)] = NoteModel(
      id: noteToUpdate.id,
      content: content,
      createdAt: noteToUpdate.createdAt,
      updatedAt: DateTime.now(),
    );
    final notesJson = jsonEncode(notes.map((n) => n.toJson()).toList());
    await prefs.setString(_key, notesJson);
  }
  
  ...
  
}

 

SharedPrefetences를 사용할 경우 특정 하나의 노트만 가져오지 못하기 때문에, 전체 노트를 가져온 후 해당하는 노트만 수정하여 다시 저장합니다.

 

전체 조회, 전체 저장과 같은 비효율은 추후 로그인 기능을 추가하고 사용자 데이터를 DB에 저장할 때 개선할 예정입니다.

 

 

 

구현 4. NoteService - 노트 수정 메서드 추가

NoteService updateNote 메서드를 추가하여 노트 수정 후 UI를 갱신합니다. 이 메서드는 NoteRepository updateNote를 호출하고, notifyListeners()로 UI에 변경 사항을 알립니다.

// lib/services/note_service.dart
class NoteService extends ChangeNotifier {
  
  ..
  
  // 노트 수정하기
  Future<void> updateNote(String id, String content) async {
    await _noteRepository.updateNote(id, content);
    notifyListeners();  // 상태가 변경되었음을 알림
  }
  
  ..
  
}

 

 

 

구현 5. NoteEditView - 노트 수정 화면 구현

NoteEditView에서 기존 노트 내용을 편집하고, 저장 버튼을 누르면 NoteService updateNote 메서드를 호출하여 내용을 업데이트합니다. 이 화면은 노트 목록의 수정 버튼을 통해 접근합니다.

 

아래는 note_edit_view 전체 코드입니다.

// lib/views/note_edit_view.dart
class NoteEditView extends StatefulWidget {
  final String noteId;

  const NoteEditView({Key? key, required this.noteId}) : super(key: key);

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

class _NoteEditViewState extends State<NoteEditView> {
  late TextEditingController _controller;

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController();
    Provider.of<NoteService>(context, listen: false)
        .getNoteById(widget.noteId)
        .then((note) {
      _controller.text = note.content;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('노트 수정', style: TextStyle(color: ColorPalette.textWhite)),
        backgroundColor: ColorPalette.background,
        actions: [
          IconButton(
            icon: Icon(Icons.save, color: Colors.white),
            onPressed: () async {
              await Provider.of<NoteService>(context, listen: false)
                  .updateNote(widget.noteId, _controller.text);
              await Provider.of<NoteService>(context, listen: false).getNotes();
              context.pop();
            },
          ),
        ],
      ),
      backgroundColor: ColorPalette.background,
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: TextField(
          controller: _controller,
          maxLines: null,
          style: TextStyle(color: ColorPalette.textWhite),
          decoration: InputDecoration(
            hintText: '노트 내용을 수정하세요',
            hintStyle: TextStyle(color: Colors.grey),
            border: InputBorder.none,
          ),
        ),
      ),
    );
  }
}

 

노트를 수정하기 위해 NoteEditView는 수정 대상 noteId를 파라미터로 받습니다. noteId로 해당하는 노트를 찾아 화면에 보여주고 내용을 수정할 수 있도록 합니다.

 

 

구현 6. NoteListView - 노트 목록에서 수정 버튼 추가

NoteListView에서 각 노트 항목에 수정 버튼을 추가합니다. 수정 버튼을 누르면 context.push('/noteEdit?id=${note.id}')을 통해 NoteEditView로 이동하여 해당 노트를 편집할 수 있습니다.

 

context.push 는 goRouter 기능을 활용한 것이기 때문에 아래처럼 패키지를 import 해야 사용할 수 있습니다.

import 'package:go_router/go_router.dart';

 

아래는 note_list_view 전체 코드입니다. 수정 버튼과 관련된 코드는 주석으로 표시하였습니다.

// lib/views/note_list_view.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: Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    IconButton( // 수정 버튼 추가
                      icon: Icon(Icons.edit, color: Colors.blue),
                      onPressed: () {
                        context.push('/noteEdit?id=${note.id}');
                      },
                    ),
                    IconButton(
                      icon: Icon(Icons.delete_outline, color: Colors.red),
                      onPressed: () async {
                        await noteService.deleteNoteById(note.id);
                      },
                    ),
                  ],
                ),
              );
            },
          );
        },
      ),
    );
  }
}

 

 

 

구현 7. main - GoRouter에 수정 화면 경로 추가

마지막으로 노트 수정 화면에 대한 GoRouter를 설정하여 경로로 호출할 수 있도록 합니다.

// lib/main.dart
import 'views/note_edit_view.dart';  // NoteEditView 추가

final GoRouter _router = GoRouter(
  initialLocation: '/noteDetail',
  routes: [
    ShellRoute(
      builder: (context, state, child) {
        return HomeScreen(
            key: homeScreenKey, child: child); 
      },
      routes: [
        
        ...

        GoRoute(
          path: '/noteEdit',
          name: PageName.noteEdit.name,
          pageBuilder: (context, state) {
            // URL 쿼리 파라미터에서 id를 가져옵니다
            final noteId = state.uri.queryParameters['id'] ?? '';
            return CustomTransitionPage(
              key: state.pageKey,
              child: NoteEditView(noteId: noteId),
              transitionDuration: const Duration(milliseconds: 200),
              transitionsBuilder: _fadeTransition,
            );
          },
        ),
        
        ...
        
      ],
    ),
  ],
);

 

다른 탭 화면들과는 다르게 노트 수정 화면은 noteId 를 파라미터로 받도록 정의했었습니다.

 

앞서 노트 목록 화면에서 context.push('/noteEdit?id=${note.id}') 를 기억하시나요? 여기에 넣은 id 를 state.uri.queryParameters['id'] 로 불러와 NoteEditView 의 파라미터로 넣어줍니다. 

 

지난 글에서 구현했던 페이드 전환을 노트 수정 화면에서도 사용하도록 CustomTransitionPage 에 담아 반환합니다.

 

 

여기까지 구현한 최종 결과를 영상으로 보겠습니다.

 

 

 

 

마무리

이번 글에서는 기존 노트 앱에 노트 수정 기능을 추가했습니다. 이를 통해 사용자는 기존에 작성한 노트를 직접 수정할 수 있게 되었습니다.

 

기능을 추가하는 과정에서 Enum과 라우팅 설정, 데이터 모델의 수정 필드 추가, 저장소 로직과 상태 관리 등 여러 요소를 다뤘습니다. 언뜻 여기저기 수정할 요소들이 많아 복잡해 보일 수도 있겠습니다. 물론 너무 많은 레이어는 가독성이 떨어지고 개발자에게 인지 부하를 초래하지만, 적절한 레이어 분리는 각각의 역할을 명확히 보여주기 때문에 맥락에 맞는 적재적소의 수정이 가능합니다.

 

만약, 개발 과정에서 레이어의 역할이 꼬이고 복잡해진다면, 수시로 리팩터링을 하여 자신의 스타일을 만들고 개선해 나가길 바랍니다.

 

 

[Flutter로 간단한 노트 앱을 만들어보자] 시리즈는 직접 독학으로 하나씩 만들어나가는 과정이므로 틀리거나 부족한 내용이 있을 수 있습니다. 조언과 피드백을 댓글로 남겨주시면 적극 반영하겠습니다. 감사합니다.

 

 


 

추가 리팩터링 - 노트 쓰기 화면, 노트 수정 화면 통합

개발을 하고 보니 노트 쓰기 화면과 수정 화면이 굳이 분리될 필요가 없음을 느꼈습니다. 이 둘은 유사한 기능을 수행하기에 화면과 UI를 통합하기로 결정했습니다.

 

먼저, NoteDetailView에 noteId를 매개변수로 받아 noteId가 있는 경우에는 수정 모드, 없는 경우에는 추가 모드로 동작하도록 구현했습니다. 이렇게 하면 추가와 수정을 하나의 화면에서 처리할 수 있습니다.

// lib/views/note_detail_view.dart
class NoteDetailView extends StatefulWidget {
  final String? noteId;  // noteId를 nullable로 설정하여 추가/수정 모드를 구분

  const NoteDetailView({super.key, this.noteId});

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

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

  @override
  void initState() {
    super.initState();
    _loadNote();  // 노트가 존재하면 수정 모드로 로드
  }

  Future<void> _loadNote() async {
    if (widget.noteId != null) {
      isEdit = true;
      final note = await Provider.of<NoteService>(context, listen: false)
          .getNoteById(widget.noteId!);
      if (note != null) {
        setState(() {
          _controller.text = note.content;
          _charCount = note.content.length;
        });
      }
    }
  }

  Future<void> _saveNote() async {
    final noteService = Provider.of<NoteService>(context, listen: false);
    if (isEdit) {
      await noteService.updateNote(widget.noteId!, _controller.text);
      await noteService.getNotes();
      context.pop();
    } else {
      await noteService.addNote(_controller.text);
      homeScreenKey.currentState?.onTabTapped(TabItem.noteList);
    }
  }
}

 

 

isEdit 플래그를 추가하여 noteId가 존재하면 기존 노트를 로드하고 편집할 수 있도록 했습니다. 노트를 저장할 때에도 isEdit 플래그를 통해 동작을 구분하여 처리합니다.

 

 

화면 타이틀도 추가 모드와 수정 모드에 따라 제목을 변경하도록 수정합니다. 타이틀은 AppBar를 통해 수정할 수 있습니다.

// lib/views/note_detail_view.dart
AppBar(
  title: Text(
    isEdit ? '노트 수정' : '노트 추가', // 추가/수정 모드에 따른 제목 변경
    style: TextStyle(color: ColorPalette.textWhite),
  ),
  backgroundColor: ColorPalette.background,
  actions: [
    IconButton(
      icon: Icon(Icons.check, color: ColorPalette.textWhite), // 저장 버튼 추가
      onPressed: _saveNote,
    ),
  ],
),

 

노트 쓰기 화면에 있던 '저장' 버튼도 체크 아이콘으로 변경하여 상단에 위치하도록 수정했습니다.

 

 

 

기존에 만들어둔 note_edit_view.dart 파일을 더이상 사용하지 않으므로 삭제합니다. 그리고 main.dart 에서 /noteEdit 경로를 정의했던 GoRoute 를 제거합니다.

 

대신 /noteDetail 경로에 state.uri.queryParameters['id'] 를 가져와 NoteDetailView에 주입합니다.

// lib/main.dart
GoRoute(
  path: '/noteDetail',
  name: TabItem.noteDetail.name,
  pageBuilder: (context, state) {
    final noteId = state.uri.queryParameters['id'];
    return CustomTransitionPage(
      key: state.pageKey,
      child: NoteDetailView(noteId: noteId), // noteId는 nullable
      transitionDuration: const Duration(milliseconds: 200),
      transitionsBuilder: _fadeTransition,
    );
  },
),

 

마지막으로 note_list_view.dart 에서 수정 버튼을 클릭했을 때 /noteEdit 가 아닌 /noteDetail 경로로 이동하도록 수정합니다.

// lib/views/note_list_view.dart
IconButton(
  icon: Icon(Icons.edit, color: Colors.blue),
  onPressed: () {
    context.push('/noteDetail?id=${note.id}');
  },
),

 

리팩터링은 여기서 마칩니다. 기존 노트 쓰기와 노트 수정 화면을 하나로 통합하여 일관된 노트 쓰기와 수정이 가능해졌습니다.

 

개발을 하다 보면 초기 작업을 수정하거나 아예 다시 해야 하는 상황이 생기곤 합니다. 이때 리팩터링을 미루면 나중에는 리팩터링이 훨씬 더 어려워지기 때문에 가능한 한 빠르게 리팩터링하는 것이 전체 개발 과정에서 더 효율적입니다.

 

이 시리즈는 완벽히 기획하여 진행하는 것이 아닌, 플러터 입문자가 직접 서비스를 만들어가는 과정을 담고 있습니다. 그렇기 때문에 중간중간 리팩터링 과정이 많이 포함되어 있으니, 이 점을 참고하며 봐주시면 감사하겠습니다.

 

 

 
반응형