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

Flutter로 멋진 노트 앱을 만들어보자 ① - 랜덤 노트 기능 추가하기

daco2020 2024. 11. 14. 11:55
반응형

더이상 간단하지 않기 때문에 '멋진' 시리즈로 이어서 연재합니다.

이번 글에서는 이전 노트 목록을 무작위로 불러오는 랜덤 노트 기능을 추가해 보겠습니다. 이 기능은 이전에 작성한 노트를 랜덤 하게 표시하여 다시 한번 내용을 복기하고 오랫동안 기억하게 도와주는 기능입니다.

 

랜덤 노트 기능에서 필요한 요구사항은 다음과 같습니다.


1. 사용자는 하단 네비게이션 바에 '랜덤 노트' 탭을 통해 랜덤 노트 화면으로 이동할 수 있다.

2. 사용자는 랜덤 노트 카드에 있는 중요, 수정, 삭제 버튼을 이용하여 노트를 관리할 수 있다.

3. 사용자는 랜덤 노트 카드를 위아래로 슬라이드 하여 다음, 이전 노트를 다시 볼 수 있다.

 

랜덤 노트 기능을 구현한 결과는 다음과 같습니다.

 

- 랜덤 노트 탭을 누르면 랜덤 노트 화면이 보입니다.

- 위아래 슬라이드를 통해 랜덤 노트 카드를 전환할 수 있습니다.

- 노트 카드와 마찬가지로 중요, 수정, 삭제 버튼을 사용할 수 있습니다.

 

 

그럼 단계별로 하나씩 구현해보겠습니다.

 

1. 하단 네비게이션 바 수정

먼저 TabItem Enum을 수정하여 탭 항목을 변경합니다. 기존 noteDetail 은 제거하고 새로운 항목 randomNote를 추가합니다. 

// enums.dart
enum TabItem { settings, randomNote, noteList }

 

 

다음으로 하단 네비게이션 바 항목을 수정합니다. 기존의 노트 쓰기 탭을 랜덤 노트로 이름과 아이콘을 변경합니다.

// bottom_navbar.dart
BottomNavigationBar(
  items: [
    BottomNavigationBarItem(
      icon: Icon(Icons.settings),
      label: '설정',
    ),
    BottomNavigationBarItem(
      icon: Icon(Icons.shuffle),
      label: '랜덤 노트',
    ),
    BottomNavigationBarItem(
      icon: Icon(Icons.list_alt_outlined),
      label: '노트 목록',
    ),
  ],
);

 

 

 

 

2. 랜덤 노트 화면 구현하기

lib/views/note_random_view.dart 파일을 생성하고 랜덤 노트를 표시할 새 화면을 구현합니다. 이 화면은 랜덤으로 하나의 노트를 표시하며, 위아래로 슬라이드 하여 다음 노트로 넘길 수 있도록 PageView 위젯을 활용하겠습니다.

// note_random_view.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:ttingnote/components/random_card.dart';
import 'package:ttingnote/models/note_model.dart';
import 'package:ttingnote/services/note_service.dart';
import 'package:ttingnote/utils/color_palette.dart';

class NoteRandomView extends StatefulWidget {
  const NoteRandomView({Key? key}) : super(key: key);

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

class _NoteRandomViewState extends State<NoteRandomView> {
  List<NoteModel> _notes = [];
  final PageController _pageController = PageController(viewportFraction: 0.9);

  @override
  void initState() {
    super.initState();
    _loadNotes();
  }

  Future<void> _loadNotes() async {
    final noteService = Provider.of<NoteService>(context, listen: false);
    await noteService.getNotes();
    setState(() {
      _notes = List.from(noteService.notes)..shuffle();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: ColorPalette.background,
      appBar: AppBar(
        title: Text('랜덤 노트', style: TextStyle(color: ColorPalette.textWhite)),
        backgroundColor: ColorPalette.background,
        actions: [
          IconButton(
            icon: Icon(Icons.shuffle, color: ColorPalette.textWhite),
            onPressed: () {
              setState(() {
                _notes.shuffle();
                _pageController.animateToPage(
                  0,
                  duration: Duration(milliseconds: 300),
                  curve: Curves.easeInOut,
                );
              });
            },
          ),
        ],
      ),
      body: Consumer<NoteService>(
        builder: (context, noteService, child) {
          if (_notes.isEmpty) {
            return Center(
              child: Text(
                '저장된 노트가 없습니다.',
                style: TextStyle(color: ColorPalette.textWhite),
              ),
            );
          }

          return PageView.builder(
            controller: _pageController,
            scrollDirection: Axis.vertical,
            itemCount: _notes.length,
            itemBuilder: (context, index) {
              return RandomCard(
                note: _notes[index],
                onNext: () {
                  _pageController.nextPage(
                    duration: Duration(milliseconds: 300),
                    curve: Curves.easeInOut,
                  );
                },
                onEdit: (id) async {
                  await context.push('/noteDetail?id=$id');
                  _loadNotes();
                },
                onDelete: (id) async {
                  await noteService.deleteNoteById(id);
                  _loadNotes();
                },
                onLabelUpdate: (id, label) async {
                  await noteService.updateNote(id, label: label);
                },
              );
            },
          );
        },
      ),
    );
  }
}

 

 

List.from(noteService.notes)..shuffle(); 로 노트 서비스에서 불러온 노트들을 랜덤 하게 섞습니다.

 

AppBar에 shuffle 아이콘을 제공하여 사용자가 새로운 랜덤 노트를 불러올 수 있도록 합니다.

 

PageView는 노트 목록을 위아래로 드래그하면서 다음 노트로 넘길 수 있도록 합니다. PageController를 사용하여 애니메이션 효과를 추가합니다.

 

RandomCard는 각 노트를 RandomCard 위젯으로 표시하며, 각 노트에 대해 중요 표시, 수정, 삭제 기능을 제공합니다.

 

 

3. 랜덤 카드(RandomCard) 구현하기

lib/components/random_card.dart 파일을 생성하고 랜덤으로 표시되는 노트 내용을 담는 RandomCard 위젯을 구현합니다.

// random_card.dart
import 'package:flutter/material.dart';
import 'package:ttingnote/models/note_model.dart';
import 'package:ttingnote/utils/color_palette.dart';
import 'package:ttingnote/components/delete_note_dialog.dart';

class RandomCard extends StatelessWidget {
  final NoteModel note;
  final VoidCallback onNext;
  final Function(String) onEdit;
  final Function(String) onDelete;
  final Function(String, NoteLabel) onLabelUpdate;

  const RandomCard({
    Key? key,
    required this.note,
    required this.onNext,
    required this.onEdit,
    required this.onDelete,
    required this.onLabelUpdate,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.of(context).size;

    return Container(
      width: size.width * 0.9,
      height: size.height * 0.7,
      margin: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
      decoration: BoxDecoration(
        color: ColorPalette.background,
        borderRadius: BorderRadius.circular(24),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.2),
            blurRadius: 8,
            offset: Offset(0, 2),
          ),
        ],
      ),
      child: Stack(
        children: [
          Align(
            alignment: Alignment.topLeft,
            child: Padding(
              padding: EdgeInsets.symmetric(horizontal: 24, vertical: 24),
              child: Text(
                note.content,
                style: TextStyle(
                  color: ColorPalette.textWhite,
                  fontSize: 18,
                  height: 1.6,
                ),
                textAlign: TextAlign.left,
              ),
            ),
          ),
          Positioned(
            right: 16,
            bottom: 16,
            child: Row(
              children: [
                _buildActionButton(
                  icon: note.label == NoteLabel.none
                      ? Icons.star_border
                      : Icons.star,
                  color: Colors.yellow,
                  onTap: () {
                    final nextLabel = note.label == NoteLabel.none
                        ? NoteLabel.important
                        : NoteLabel.none;
                    onLabelUpdate(note.id, nextLabel);
                  },
                ),
                _buildActionButton(
                  icon: Icons.edit_outlined,
                  color: Colors.blue,
                  onTap: () => onEdit(note.id),
                ),
                _buildActionButton(
                  icon: Icons.delete_outline,
                  color: Colors.red,
                  onTap: () {
                    showDialog(
                      context: context,
                      builder: (BuildContext context) => DeleteNoteDialog(
                        onDelete: () => onDelete(note.id),
                      ),
                    );
                  },
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildActionButton({
    required IconData icon,
    required Color color,
    required VoidCallback onTap,
  }) {
    return InkWell(
      onTap: onTap,
      child: Container(
        padding: EdgeInsets.all(12),
        child: Icon(
          icon,
          color: color,
          size: 24,
        ),
      ),
    );
  }
}

 

Text 위젯으로 노트의 내용을 표시하고 기존 노트 카드에 있던 중요, 수정, 삭제 버튼을 추가해 줍니다. 랜덤 노트 카드는 카드를 누르지 않아도 해당 버튼들이 표시되도록 했습니다. 

 

추가로 기존 노트 카드에 있던 삭제 안내 모달을 DeleteNoteDialog라는 컴포넌트로 분리합니다.

// lib/components/delete_note_dialog.dart

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

class DeleteNoteDialog extends StatelessWidget {
  final Function() onDelete;

  const DeleteNoteDialog({
    Key? key,
    required this.onDelete,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      backgroundColor: ColorPalette.background,
      title: Text(
        '노트 삭제',
        style: TextStyle(color: ColorPalette.textWhite),
      ),
      content: Text(
        '정말 삭제하시겠습니까?',
        style: TextStyle(color: ColorPalette.textWhite),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.of(context).pop(),
          child: Text(
            '취소',
            style: TextStyle(color: Colors.grey),
          ),
        ),
        TextButton(
          onPressed: () {
            Navigator.of(context).pop();
            onDelete();
          },
          child: Text(
            '삭제',
            style: TextStyle(color: Colors.red),
          ),
        ),
      ],
    );
  }
}

 

이렇게 분리하면 노트 카드에도, 랜덤 노트 카드에도 동일한 UI를 재사용할 수 있습니다. 재사용이 필요한 UI요소들은 이렇게 컴포넌트로 분리해 주세요.

 

 

4. GoRoute 경로 추가

main.dart 파일 GoRoute에 랜덤 노트 경로를 추가합니다.

// main.dart
GoRoute(
  path: '/randomNote',
  name: TabItem.randomNote.name,
  pageBuilder: (context, state) => CustomTransitionPage(
    key: state.pageKey,
    child: NoteRandomView(),
    transitionDuration: const Duration(milliseconds: 200),
    transitionsBuilder: _fadeTransition,
  ),
),

 

이제 앱의 하단 네비게이션 바에서 랜덤 노트 아이콘을 클릭하면 랜덤 노트 화면으로 이동할 수 있습니다.

 

 

랜덤 노트 기능의 최종 결과를 영상으로 보겠습니다.

 

 

 

 

마무리

이번 글에서는 랜덤 노트 탭을 추가하고, 무작위로 노트를 확인하는 기능을 구현해 보았습니다.

 

제가 랜덤 노트 기능을 만든 이유는 단순히 노트를 나열하는 것을 넘어서 사용자에게 새로운 노트 탐색의 기회를 제공하고 싶었기 때문입니다. 기존에 작성한 노트를 다시 열어볼 확률을 조금이라도 높이는 것이 랜덤 노트의 목적이죠. 

 

처음에는 리마인드 기능을 생각했었습니다. 특정 시간에 다시 보고 싶은 노트를 보여주는 것이죠. 하지만 리마인드는 본질적으로 수동적입니다. 알림이 오더라도 내가 바쁘거나 귀찮으면 알림을 무시해 버리죠. 나중에는 알림이 잔소리처럼 느껴져 서비스에 대한 부정적 감정이 생기기도 합니다. 

 

랜덤 노트는 리마인드와 달리 사용자가 직접 들어가 확인합니다. 자신이 보고 싶은 노트만 골라 볼 수는 없지만, 자신이 과거에 적어둔 노트들을 무작위로 보여주며 기대감을 제공합니다. 저는 이런 기대감이 서비스에 대한 긍정적 감정을 줄 뿐만 아니라 시간이 날 때마다 노트를 확인하는, 서비스 리텐션에도 긍정적 영향을 줄 거라 생각합니다.

 

실제로 저의 이런 가설이 맞는지 확인하기 위해 앱을 출시하고 사용자 반응을 살펴보겠습니다.

 

다음 글에서는 앱을 본격적으로 출시하기 전에 마지막으로 [설정] 탭을 구현해 보겠습니다.

 

 

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

 

 

반응형