크로스플랫폼 노트 앱 개발기 시리즈 #1 프로젝트 설계 | #2 로컬 DB와 CRUD | #3 UI와 4플랫폼 빌드 | #4 3종 에디터 | #5 검색 기능 | #6 테마/설정
#4 — 3종 에디터: Plain, Markdown, Checklist
이전까지는 일반 텍스트 노트만 있었다. 이번에는 마크다운과 체크리스트 타입을 추가해서, 용도에 맞는 에디터를 제공한다. 하나의 EditorScreen이 NoteType에 따라 적절한 에디터 위젯을 선택하는 Strategy 패턴을 적용했다.
1. NoteType 열거형 정의
노트 타입을 enum으로 정의했다. plain, markdown, checklist 세 가지다.
enum NoteType {
plain('plain'),
markdown('markdown'),
checklist('checklist');
const NoteType(this._wire);
final String _wire;
String toJson() => _wire;
static NoteType fromJson(String value) {
return NoteType.values.firstWhere(
(t) => t._wire == value,
orElse: () => throw ArgumentError.value(value, 'value', 'unknown NoteType'),
);
}
}
_wire 필드는 DB와 JSON에 저장되는 문자열 값이다. toJson()과 fromJson()으로 직렬화/역직렬화를 직접 제어한다. Dart enum의 name 프로퍼티를 쓸 수도 있지만, 나중에 enum 이름을 리팩토링해도 저장된 값이 깨지지 않도록 별도 와이어 값을 두었다.
Note 엔티티에도 type 필드를 추가한다:
@freezed
class Note with _$Note {
const factory Note({
required String id,
required NoteType type, // 추가
required String title,
required String content,
required DateTime createdAt,
required DateTime updatedAt,
}) = _Note;
factory Note.fromJson(Map<String, dynamic> json) => _$NoteFromJson(json);
}
2. DB v2 마이그레이션 — type 컬럼과 FTS5
기존 DB 스키마(v1)에는 type 컬럼이 없었다. Drift의 버전 관리 마이그레이션으로 v2로 올린다.
테이블 스키마
@DataClassName('NoteRow')
class Notes extends Table {
TextColumn get id => text()();
TextColumn get type => text().withDefault(const Constant('plain'))();
TextColumn get title => text().withDefault(const Constant(''))();
TextColumn get content => text().withDefault(const Constant(''))();
TextColumn get searchText => text().withDefault(const Constant(''))();
IntColumn get createdAt => integer()();
IntColumn get updatedAt => integer()();
@override
Set<Column> get primaryKey => {id};
}
type 컬럼은 기본값이 'plain'이다. 기존 노트는 마이그레이션 시 자동으로 plain 타입이 된다. searchText 컬럼은 전문 검색(FTS5)을 위해 함께 추가했다.
마이그레이션 로직
@override
int get schemaVersion => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) async {
await m.createAll();
await _createFtsAndTriggers(this);
},
onUpgrade: (m, from, to) async {
if (from == 1) {
// 1) type, searchText 컬럼 추가
await m.addColumn(notes, notes.type);
await m.addColumn(notes, notes.searchText);
// 2) 기존 노트의 searchText 채우기
await customStatement(
"UPDATE notes SET search_text = title || x'0a' || content",
);
// 3) FTS 가상 테이블 + 트리거 생성
await _createFtsAndTriggers(this);
}
},
);
마이그레이션은 세 단계로 진행된다:
- 컬럼 추가:
type과searchText두 컬럼을 추가한다.m.addColumn()은 Drift가 제공하는 안전한ALTER TABLE ADD COLUMN래퍼다. - 기존 데이터 채우기: v1 시절에 만들어진 노트들은
searchText가 비어 있으므로,title + 줄바꿈 + content로 채워준다.x'0a'는 SQLite에서 줄바꿈(LF) 문자를 나타내는 hex 리터럴이다. - FTS5 가상 테이블과 트리거 생성: 검색 기능의 핵심인데, 이건 다음 편에서 자세히 다룬다.
FTS5 가상 테이블과 트리거
검색 기능에 필요한 FTS5 테이블과 동기화 트리거를 함께 생성한다. 새 설치(onCreate)와 업그레이드(onUpgrade) 양쪽에서 동일한 코드를 실행하도록 _createFtsAndTriggers()로 추출했다.
Future<void> _createFtsAndTriggers(LocalDb db) async {
// FTS5 가상 테이블 — content sync 모드
await db.customStatement('''
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
id UNINDEXED,
title,
search_text,
content='notes',
content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
);
''');
// INSERT 트리거
await db.customStatement('''
CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts(rowid, id, title, search_text)
VALUES (new.rowid, new.id, new.title, new.search_text);
END;
''');
// DELETE 트리거
await db.customStatement('''
CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, id, title, search_text)
VALUES ('delete', old.rowid, old.id, old.title, old.search_text);
END;
''');
// UPDATE 트리거 (delete + insert)
await db.customStatement('''
CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, id, title, search_text)
VALUES ('delete', old.rowid, old.id, old.title, old.search_text);
INSERT INTO notes_fts(rowid, id, title, search_text)
VALUES (new.rowid, new.id, new.title, new.search_text);
END;
''');
// 기존 데이터로 FTS 인덱스 재구축
await db.customStatement("INSERT INTO notes_fts(notes_fts) VALUES('rebuild')");
}
핵심 포인트 몇 가지:
- content sync 모드 (
content='notes'): FTS 테이블이 데이터를 자체 저장하지 않고, 원본notes테이블을 참조한다. 저장 공간이 절반으로 줄어든다. - 트리거 3종: notes 테이블에 INSERT/DELETE/UPDATE가 발생하면 FTS 인덱스가 자동으로 동기화된다. UPDATE 트리거는 기존 레코드를 삭제하고 다시 삽입하는 방식이다 (FTS5의 content sync 방식이 이렇게 요구한다).
- tokenize=’unicode61’: 한글/영문/숫자를 모두 토큰화할 수 있는 유니코드 토크나이저를 사용한다.
FTS5 검색 기능 자체는 다음 편(#5)에서 상세히 다룬다. 이번 편에서는 마이그레이션과 함께 인프라를 미리 깔아두는 것이다.
3. Checklist 엔티티
체크리스트 노트의 본문은 JSON 문자열로 저장된다. 이를 다루기 위한 별도 엔티티를 정의했다.
@freezed
class ChecklistItem with _$ChecklistItem {
const factory ChecklistItem({
required String id,
required String text,
required bool checked,
required int order,
}) = _ChecklistItem;
factory ChecklistItem.fromJson(Map<String, dynamic> json) =>
_$ChecklistItemFromJson(json);
}
@freezed
class Checklist with _$Checklist {
const Checklist._();
const factory Checklist({required List<ChecklistItem> items}) = _Checklist;
factory Checklist.empty() => const Checklist(items: []);
/// DB의 content 컬럼에 저장할 JSON 문자열 생성
String asContent() => jsonEncode(toJson());
/// content 컬럼에서 파싱. 에러 시 빈 체크리스트 반환
static Checklist parseContent(String raw) {
if (raw.isEmpty) return Checklist.empty();
try {
final map = jsonDecode(raw) as Map<String, dynamic>;
return Checklist.fromJson(map);
} on FormatException {
return Checklist.empty();
} on TypeError {
return Checklist.empty();
}
}
}
설계 포인트:
- DB 스키마를 건드리지 않는다: 체크리스트를 위한 별도 테이블을 만들지 않고, 기존
content컬럼에 JSON 문자열로 저장한다.asContent()로 직렬화하고,parseContent()로 역직렬화한다. - 방어적 파싱:
parseContent()는FormatException이나TypeError가 발생하면 빈 체크리스트를 반환한다. 앱이 죽는 것보다 빈 데이터로 복구하는 게 낫다. - order 필드: 각 항목의 순서를 명시적으로 관리한다. 드래그 앤 드롭으로 순서가 변경되면 이 값이 업데이트된다.
- id 필드: 각 항목에 UUID를 부여해서 항목의 고유성을 보장한다. 순서가 바뀌어도 어떤 항목인지 추적할 수 있다.
4. TypePicker — 노트 종류 선택 다이얼로그
새 노트를 만들 때 타입을 선택하는 바텀시트를 구현했다.
Future<NoteType?> showTypePicker(BuildContext context) {
return showModalBottomSheet<NoteType>(
context: context,
showDragHandle: true,
builder: (ctx) => const _TypePickerSheet(),
);
}
class _TypePickerSheet extends StatelessWidget {
const _TypePickerSheet();
@override
Widget build(BuildContext context) {
return const SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Text('노트 종류 선택',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
),
_TypeTile(
type: NoteType.plain,
icon: Icons.notes,
label: '일반 텍스트',
description: '단순한 메모',
),
_TypeTile(
type: NoteType.markdown,
icon: Icons.code,
label: 'Markdown',
description: '서식과 단축키 지원',
),
_TypeTile(
type: NoteType.checklist,
icon: Icons.checklist,
label: '체크리스트',
description: '할 일 / 쇼핑 목록',
),
SizedBox(height: 16),
],
),
);
}
}
노트 목록 화면의 FAB(+) 버튼을 누르면 이 바텀시트가 올라오고, 사용자가 타입을 선택하면 해당 타입의 에디터가 열린다:
// NoteListScreen의 FAB
floatingActionButton: FloatingActionButton(
onPressed: () async {
final picked = await showTypePicker(context);
if (picked == null) return;
if (!context.mounted) return;
context.push('/editor?type=${picked.toJson()}');
},
child: const Icon(Icons.add),
),
각 타입별 아이콘은 노트 목록에서도 동일하게 사용된다. _iconFor() 함수로 타입에 따른 leading 아이콘을 분기한다:
static IconData _iconFor(NoteType type) {
switch (type) {
case NoteType.plain:
return Icons.notes;
case NoteType.markdown:
return Icons.code;
case NoteType.checklist:
return Icons.checklist;
}
}
5. Strategy 패턴 — EditorScreen의 에디터 분기
이 프로젝트의 에디터 구조에서 가장 중요한 설계 결정이다. 하나의 EditorScreen이 NoteType에 따라 서로 다른 에디터 위젯을 선택한다.
왜 Strategy 패턴인가
에디터를 구현하는 방법은 여러 가지가 있다:
- 하나의 거대한 에디터: if/else로 분기해서 모든 로직을 한 위젯에 넣는다 — 코드가 금방 복잡해진다.
- 별도 화면 3개: PlainEditorScreen, MarkdownEditorScreen, ChecklistEditorScreen을 따로 만든다 — 제목 입력, 저장 로직 같은 공통 코드가 중복된다.
- Strategy 패턴: 공통 부분(제목, 저장)은 EditorScreen이 담당하고, 본문 편집만 타입별 위젯에 위임한다.
3번을 선택했다. 공통 코드는 한 곳에, 에디터별 차이는 각자의 위젯에. 새로운 타입이 추가되면 위젯 하나와 switch 분기 하나만 추가하면 된다.
EditorScreen 핵심 코드
class EditorScreen extends ConsumerStatefulWidget {
const EditorScreen({
super.key,
required this.noteId,
required this.initialType,
});
final String? noteId;
final NoteType initialType;
@override
ConsumerState<EditorScreen> createState() => _EditorScreenState();
}
noteId가 null이면 새 노트 생성, 값이 있으면 기존 노트 편집이다. initialType은 TypePicker에서 선택한 타입이다.
에디터 분기 메서드
Widget _buildEditorFor(NoteType type) {
final key = ValueKey('${type.name}-${widget.noteId ?? 'new'}');
final fontSize = ref.watch(editorFontSizeControllerProvider).points;
switch (type) {
case NoteType.plain:
return PlainEditor(
key: key,
initialContent: _body,
onChanged: (v) => _body = v,
fontSize: fontSize,
);
case NoteType.markdown:
return MarkdownEditor(
key: key,
initialContent: _body,
onChanged: (v) => _body = v,
fontSize: fontSize,
);
case NoteType.checklist:
return ChecklistEditor(
key: key,
initialContent: _body,
onChanged: (v) => _body = v,
fontSize: fontSize,
);
}
}
세 에디터 위젯은 동일한 인터페이스를 따른다:
| 파라미터 | 역할 |
|---|---|
initialContent |
에디터에 초기 표시할 본문 |
onChanged |
본문이 변경될 때마다 호출되는 콜백 |
fontSize |
설정에서 지정한 에디터 폰트 크기 |
EditorScreen은 이 인터페이스만 알고 있으면 된다. 각 에디터 내부에서 텍스트를 어떻게 편집하든 — 마크다운 단축키든, 체크박스 토글이든 — EditorScreen은 신경 쓸 필요가 없다.
저장 로직
Future<void> _save() async {
final actions = ref.read(editorActionsProvider.notifier);
if (_loaded == null) {
await actions.create(type: _type, title: _titleCtrl.text, content: _body);
} else {
await actions.update(
_loaded!,
title: _titleCtrl.text,
content: _body,
);
}
if (mounted) context.pop();
}
새 노트면 create(), 기존 노트면 update()를 호출한다. 타입에 관계없이 _body에는 항상 문자열이 들어 있다 — 일반 텍스트는 그대로, 마크다운도 그대로, 체크리스트는 JSON 문자열이다. 저장 로직이 타입을 알 필요 없이 동일하게 동작한다.
6. PlainEditor — 일반 텍스트 에디터
가장 단순한 에디터다. Flutter의 TextField를 감싸는 얇은 래퍼 위젯이다.
class PlainEditor extends StatefulWidget {
const PlainEditor({
super.key,
required this.initialContent,
required this.onChanged,
this.fontSize,
});
final String initialContent;
final void Function(String) onChanged;
final double? fontSize;
@override
State<PlainEditor> createState() => _PlainEditorState();
}
class _PlainEditorState extends State<PlainEditor> {
late final TextEditingController _ctrl;
@override
void initState() {
super.initState();
_ctrl = TextEditingController(text: widget.initialContent);
_ctrl.addListener(() => widget.onChanged(_ctrl.text));
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _ctrl,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: widget.fontSize != null
? TextStyle(fontSize: widget.fontSize)
: null,
decoration: const InputDecoration(
labelText: '본문',
alignLabelWithHint: true,
border: OutlineInputBorder(),
),
);
}
}
maxLines: null과 expands: true를 함께 쓰면 TextField가 부모 크기만큼 확장되면서 여러 줄 입력이 가능해진다. textAlignVertical: TextAlignVertical.top으로 커서가 좌상단에서 시작하도록 했다.
이전에는 이 코드가 EditorScreen 안에 인라인으로 있었다. 마크다운/체크리스트 에디터를 추가하면서 별도 위젯으로 추출한 것이다.
7. MarkdownEditor — 서식 단축키와 라이브 미리보기
마크다운 에디터는 두 가지 핵심 기능을 제공한다: 키보드 단축키와 미리보기 토글.
키보드 단축키 구현
Flutter의 Shortcuts + Actions 위젯으로 키보드 단축키를 처리한다.
Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.keyB):
const _BoldIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyB):
const _BoldIntent(),
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.keyI):
const _ItalicIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyI):
const _ItalicIntent(),
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.keyK):
const _LinkIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyK):
const _LinkIntent(),
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.shift,
LogicalKeyboardKey.keyK): const _CodeIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift,
LogicalKeyboardKey.keyK): const _CodeIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
_BoldIntent: CallbackAction<_BoldIntent>(
onInvoke: (_) {
_apply((t, s) => applyBold(text: t, selection: s));
return null;
},
),
// ... 나머지 Intent 처리
},
child: /* 에디터 UI */,
),
),
단축키 매핑:
| 단축키 | macOS | Windows/Linux | 동작 |
|---|---|---|---|
| 볼드 | Cmd+B | Ctrl+B | **선택텍스트** |
| 이탤릭 | Cmd+I | Ctrl+I | *선택텍스트* |
| 링크 | Cmd+K | Ctrl+K | [선택텍스트](url) |
| 인라인 코드 | Cmd+Shift+K | Ctrl+Shift+K | `선택텍스트` |
macOS와 Windows 양쪽 키를 모두 등록한 이유는 크로스플랫폼 앱이기 때문이다. macOS에서는 meta(Cmd)를, Windows/Linux에서는 control(Ctrl)을 사용한다.
마크다운 서식 적용 함수
markdown_shortcuts.dart에 서식 적용 로직을 순수 함수로 분리했다.
class EditorResult {
const EditorResult(this.text, this.selection);
final String text;
final TextSelection selection;
}
EditorResult _wrap({
required String text,
required TextSelection selection,
required String left,
required String right,
}) {
final start = selection.start.clamp(0, text.length);
final end = selection.end.clamp(0, text.length);
// 이미 감싸져 있으면 언랩 (토글)
if (start >= left.length &&
end + right.length <= text.length &&
text.substring(start - left.length, start) == left &&
text.substring(end, end + right.length) == right) {
final newText = text.substring(0, start - left.length) +
text.substring(start, end) +
text.substring(end + right.length);
return EditorResult(
newText,
TextSelection(
baseOffset: start - left.length,
extentOffset: end - left.length,
),
);
}
// 감싸기
final newText = text.substring(0, start) +
left + text.substring(start, end) + right +
text.substring(end);
return EditorResult(
newText,
TextSelection(
baseOffset: start + left.length,
extentOffset: end + left.length,
),
);
}
_wrap() 함수의 핵심 설계:
- 토글 동작: Cmd+B를 한 번 누르면 볼드가 적용되고, 다시 누르면 해제된다. 선택 영역 양쪽에
**가 이미 있는지 검사해서 있으면 제거, 없으면 추가한다. - 커서 위치 보존: 서식을 적용한 후에도 선택 영역이 원래 텍스트 위에 유지된다. 삽입된 마크다운 기호 길이만큼 오프셋을 조정한다.
- 순수 함수: UI 상태에 의존하지 않는다. 텍스트와 선택 범위를 받아서 새로운 텍스트와 선택 범위를 반환할 뿐이다. 테스트가 쉽다.
각 서식 함수는 _wrap()을 호출하기만 한다:
EditorResult applyBold({required String text, required TextSelection selection}) =>
_wrap(text: text, selection: selection, left: '**', right: '**');
EditorResult applyItalic({required String text, required TextSelection selection}) =>
_wrap(text: text, selection: selection, left: '*', right: '*');
EditorResult applyInlineCode({required String text, required TextSelection selection}) =>
_wrap(text: text, selection: selection, left: '`', right: '`');
링크 삽입은 조금 다르다. 선택한 텍스트를 링크 텍스트로 사용하고, URL 부분에 커서를 이동시킨다:
EditorResult applyLink({required String text, required TextSelection selection}) {
final start = selection.start.clamp(0, text.length);
final end = selection.end.clamp(0, text.length);
final selected = text.substring(start, end);
final label = selected.isEmpty ? 'text' : selected;
const urlPlaceholder = 'url';
final inserted = '[$label]($urlPlaceholder)';
final newText = text.substring(0, start) + inserted + text.substring(end);
final urlStart = start + '[$label]('.length;
return EditorResult(
newText,
TextSelection(baseOffset: urlStart, extentOffset: urlStart + urlPlaceholder.length),
);
}
“Flutter”를 선택하고 Cmd+K를 누르면 [Flutter](url)이 되고, url 부분이 선택된 상태가 된다. 바로 실제 URL을 타이핑하면 된다.
미리보기 토글
편집 모드와 렌더링된 마크다운 미리보기를 전환할 수 있다.
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () => setState(() => _previewOn = !_previewOn),
icon: Icon(_previewOn ? Icons.edit : Icons.visibility),
label: Text(_previewOn ? '편집' : '미리보기'),
),
],
),
Expanded(
child: _previewOn
? Markdown(
data: _ctrl.text,
selectable: true,
padding: const EdgeInsets.symmetric(horizontal: 4),
)
: TextField(
controller: _ctrl,
maxLines: null,
expands: true,
// ...
),
),
_previewOn 상태 하나로 편집기와 flutter_markdown 패키지의 Markdown 위젯을 전환한다. 미리보기 상태에서도 텍스트를 선택(selectable)할 수 있게 했다.
8. ChecklistEditor — 체크박스 토글, 추가/삭제, 드래그 정렬
체크리스트 에디터는 가장 기능이 많은 에디터다. 체크박스 토글, 항목 추가/삭제, 드래그 앤 드롭 정렬을 모두 지원한다.
상태 관리
class _ChecklistEditorState extends State<ChecklistEditor> {
late List<ChecklistItem> _items;
final _uuid = const Uuid();
final Map<String, TextEditingController> _controllers = {};
@override
void initState() {
super.initState();
_items = List.of(Checklist.parseContent(widget.initialContent).items);
for (final item in _items) {
_controllers[item.id] = TextEditingController(text: item.text);
}
}
}
각 항목마다 별도의 TextEditingController를 Map으로 관리한다. 이렇게 하는 이유가 있다:
- 드래그 앤 드롭으로 항목 순서가 바뀌면 위젯 트리가 재구성된다.
- 만약 인덱스 기반으로 컨트롤러를 관리하면, 순서 변경 후 컨트롤러와 항목이 어긋난다.
item.id를 키로 사용하면 순서가 바뀌어도 각 항목의 텍스트 상태가 정확하게 유지된다.
핵심 동작 함수들
void _emit() {
// order 값을 0부터 순차적으로 재할당
final canonical = <ChecklistItem>[
for (var i = 0; i < _items.length; i++) _items[i].copyWith(order: i),
];
_items = canonical;
final serialized = Checklist(items: canonical).asContent();
widget.onChanged(serialized);
}
void _toggle(String id) {
setState(() {
final idx = _items.indexWhere((e) => e.id == id);
if (idx < 0) return;
_items[idx] = _items[idx].copyWith(checked: !_items[idx].checked);
_emit();
});
}
void _add() {
setState(() {
final id = _uuid.v4();
_items.add(ChecklistItem(id: id, text: '', checked: false, order: _items.length));
_controllers[id] = TextEditingController();
_emit();
});
}
void _remove(String id) {
setState(() {
_items.removeWhere((e) => e.id == id);
_controllers.remove(id)?.dispose();
_emit();
});
}
모든 변경 함수는 마지막에 _emit()을 호출한다. _emit()은 order 값을 0부터 재할당하고, JSON으로 직렬화해서 onChanged 콜백을 호출한다. EditorScreen 입장에서는 그냥 문자열이 변경된 것이다.
드래그 앤 드롭 정렬
Flutter의 ReorderableListView.builder를 사용한다.
void _reorder(int oldIndex, int newIndex) {
setState(() {
var adjustedNew = newIndex;
if (newIndex > oldIndex) adjustedNew -= 1;
final moved = _items.removeAt(oldIndex);
_items.insert(adjustedNew, moved);
_emit();
});
}
ReorderableListView의 onReorder 콜백은 약간 특이한 인덱스 규칙을 가진다. 아래로 이동할 때 newIndex가 이동 후가 아닌 이동 전 기준이라서 1을 빼야 한다. 이건 Flutter 공식 문서에도 명시된 동작이다.
체크리스트 아이템 UI
itemBuilder: (context, i) {
final item = _items[i];
final ctrl = _controllers[item.id]!;
return Padding(
key: ValueKey(item.id),
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
child: Row(
children: [
Checkbox(
value: item.checked,
onChanged: (_) => _toggle(item.id),
),
Expanded(
child: TextField(
controller: ctrl,
onChanged: (v) => _editText(item.id, v),
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: '항목을 입력하세요',
),
style: TextStyle(
fontSize: widget.fontSize,
decoration: item.checked
? TextDecoration.lineThrough : null,
color: item.checked
? Theme.of(context).disabledColor : null,
),
),
),
IconButton(
tooltip: '삭제',
icon: const Icon(Icons.close),
onPressed: () => _remove(item.id),
),
ReorderableDragStartListener(
index: i,
child: const Icon(Icons.drag_handle),
),
],
),
);
},
각 항목은 [체크박스] [텍스트 입력] [삭제 버튼] [드래그 핸들] 순으로 배치된다.
- 체크박스: 체크하면 텍스트에 취소선(
lineThrough)이 그려지고 색상이 흐려진다. - 드래그 핸들:
buildDefaultDragHandles: false로 기본 드래그 핸들을 비활성화하고,ReorderableDragStartListener로 드래그 핸들 아이콘에서만 드래그가 시작되도록 했다. 텍스트 입력 중 실수로 드래그가 발동하는 것을 방지한다. - ValueKey:
ValueKey(item.id)로 각 항목을 식별한다.ReorderableListView가 항목을 올바르게 추적하려면 고유한 Key가 필수다.
항목 추가 버튼
Padding(
padding: const EdgeInsets.all(8),
child: OutlinedButton.icon(
onPressed: _add,
icon: const Icon(Icons.add),
label: const Text('항목 추가'),
),
),
리스트 하단에 고정 배치된 버튼이다. 누르면 빈 항목이 추가되고 즉시 텍스트를 입력할 수 있다.
9. 전체 흐름 정리
새 노트를 만들 때의 전체 흐름을 정리해보자:
1. NoteListScreen의 FAB(+) 클릭
2. showTypePicker() → 바텀시트에서 타입 선택
3. /editor?type=markdown 으로 라우팅
4. EditorScreen 생성 (initialType = markdown)
5. _buildEditorFor(NoteType.markdown) → MarkdownEditor 위젯 반환
6. 사용자가 제목/본문 작성, Cmd+B로 볼드 등 단축키 사용
7. 저장 버튼 → EditorActions.create(type: markdown, ...)
8. Note 엔티티 생성 → Repository.save() → DB 저장
9. 노트 목록으로 돌아감 → 마크다운 아이콘(Icons.code)과 함께 표시
기존 노트를 열 때는 2~3이 생략되고, DB에서 불러온 Note의 type으로 에디터가 결정된다.
이번 편 정리
| 항목 | 내용 |
|---|---|
| NoteType enum | plain, markdown, checklist — DB에는 문자열로 저장 |
| DB v2 마이그레이션 | type + searchText 컬럼 추가, FTS5 가상 테이블 + 트리거 3종 |
| Checklist 엔티티 | freezed 기반 불변 객체, content 컬럼에 JSON으로 저장 |
| TypePicker | 바텀시트로 노트 종류 선택, 타입별 아이콘/설명 제공 |
| Strategy 패턴 | EditorScreen → switch(type) → PlainEditor / MarkdownEditor / ChecklistEditor |
| PlainEditor | TextField 래퍼, expands: true로 전체 영역 활용 |
| MarkdownEditor | Cmd+B/I/K 단축키(토글), Cmd+Shift+K 인라인 코드, 미리보기 전환 |
| ChecklistEditor | 체크박스 토글(취소선), 항목 추가/삭제, ReorderableListView 드래그 정렬 |
하나의 EditorScreen이 3가지 에디터를 전략적으로 분기하는 구조를 만들었다. 새로운 노트 타입이 추가되어도 EditorScreen의 변경은 switch 분기 하나뿐이다. 각 에디터는 자신의 편집 방식만 책임지고, 나머지는 공통 인프라가 처리한다.
다음 편에서는 SQLite FTS5를 활용한 전문 검색 기능을 구현한다.
← 이전: #3 UI 구현과 4플랫폼 빌드 → 다음: #5 검색 기능 — SQLite FTS5